Skip to main content

task_runs/
driver.rs

1//! @arch:layer(kg_store)
2//! @arch:role(substrate)
3//! @arch:see(.yah/docs/working/yah-task-runs.md)
4//!
5//! PTY subprocess driver — spawn commands, capture output as append-only
6//! chunks, handle SIGTERM/SIGKILL with a grace period, and mark stale
7//! `Running` runs as `Lost` when the daemon restarts.
8//!
9//! ## Tier 2 side-channel (yah-log shims)
10//!
11//! When `SpawnOpts::log_fd_enabled` is true (the default), the driver creates
12//! a named pipe (FIFO) and exports two env vars into the child:
13//!
14//! - `YAH_TASK_RUN`  — the `TaskRunId` as a hyphenated UUID string.
15//! - `YAH_LOG_PIPE`  — absolute path to the FIFO.
16//!
17//! The child opens `YAH_LOG_PIPE` for writing and emits JSON-lines. The
18//! driver reads those lines in a background thread and stores them as
19//! [`EventSource::Shim`] events.
20//!
21//! **Why FIFO instead of a raw fd?** `portable-pty` calls `close_random_fds()`
22//! in its `pre_exec` hook, closing every fd ≥ 3 before exec. A raw-pipe write
23//! fd is always ≥ 3 and would be closed before the child could use it. Opening
24//! a FIFO by path requires no fd inheritance.
25//!
26//! Wire format — one JSON object per line:
27//! ```json
28//! {"level":"info","target":"myapp::module","msg":"text","fields":{"key":"val"}}
29//! ```
30//! Optional shim-identity keys: `"_lib"` (string), `"_lib_ver"` (string).
31//! Unknown keys in `fields` pass through as freeform JSON.
32//!
33//! The driver holds the write end of the FIFO open until the run lifecycle
34//! task completes, which triggers EOF for the receiver thread. The FIFO file
35//! is deleted after the receiver thread drains the last line.
36//!
37//! On non-Unix platforms `YAH_TASK_RUN` and `YAH_LOG_PIPE` are not exported.
38//! Shim libraries must treat absent `YAH_TASK_RUN` as "not inside a TaskRun".
39
40use std::collections::HashMap;
41use std::io::Read;
42use std::path::PathBuf;
43use std::sync::{Arc, Mutex};
44use std::time::{Duration, SystemTime, UNIX_EPOCH};
45
46use portable_pty::{native_pty_system, CommandBuilder, PtySize};
47use thiserror::Error;
48use tokio::sync::{mpsc, oneshot};
49use tokio::task;
50
51use crate::beholders::{registry_with_user_beholders, BeholderSelect};
52use crate::store::{RunFilter, StoreError, TaskStore};
53use crate::types::{BeholderStatus, Initiator, OutputChunk, RunStatus, Stream, TaskRunId, TaskRunMeta};
54
55const DEFAULT_GRACE: Duration = Duration::from_secs(5);
56const READ_BUF_SIZE: usize = 4096;
57const SIGTERM: i32 = 15;
58const SIGKILL: i32 = 9;
59
60// ─── Error ────────────────────────────────────────────────────────────────────
61
62#[derive(Debug, Error)]
63pub enum DriverError {
64    #[error("store: {0}")]
65    Store(#[from] StoreError),
66    #[error("pty: {0}")]
67    Pty(String),
68    #[error("run not found: {0}")]
69    NotFound(String),
70    #[error("io: {0}")]
71    Io(#[from] std::io::Error),
72}
73
74// ─── SpawnOpts ────────────────────────────────────────────────────────────────
75
76/// Options for [`TaskDriver::spawn_run`].
77#[derive(Debug, Clone)]
78pub struct SpawnOpts {
79    pub cwd: PathBuf,
80    /// Env vars set on the child process (merged on top of the current env).
81    pub env: Vec<(String, String)>,
82    pub label: Option<String>,
83    pub initiator: Initiator,
84    /// PTY column count. Defaults to 80.
85    pub pty_cols: u16,
86    /// PTY row count. Defaults to 24.
87    pub pty_rows: u16,
88    /// Enable stdin relay via [`TaskDriver::send_stdin`].
89    pub stdin_enabled: bool,
90    /// Pin the run so the GC sweep does not drop its output during warm rolloff.
91    pub pin: bool,
92    /// Beholder attachment policy. Defaults to [`BeholderSelect::Auto`].
93    pub beholder_select: BeholderSelect,
94    /// `true` when a human-facing terminal tile is attached. Causes `Rewriter`
95    /// beholders to decline in `Auto` mode so the human sees unmodified output.
96    pub tty_attached: bool,
97    /// Create a side-channel FIFO and export `YAH_TASK_RUN` / `YAH_LOG_PIPE`
98    /// so Tier-2 shim libraries (yah-log-rust, @yah/log) can emit structured
99    /// events. Has no effect on non-Unix platforms. Defaults to `true`.
100    pub log_fd_enabled: bool,
101    /// Provenance tag stored on the run's `TaskRunMeta.origin` (e.g.
102    /// `Some("terminal")` for an interactive shell). `None` is an ordinary job.
103    pub origin: Option<String>,
104}
105
106impl Default for SpawnOpts {
107    fn default() -> Self {
108        Self {
109            cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
110            env: vec![],
111            label: None,
112            initiator: Initiator::Human { camp: "local".to_string() },
113            pty_cols: 80,
114            pty_rows: 24,
115            stdin_enabled: false,
116            pin: false,
117            beholder_select: BeholderSelect::Auto,
118            tty_attached: false,
119            log_fd_enabled: true,
120            origin: None,
121        }
122    }
123}
124
125// ─── Internal run-control handle ─────────────────────────────────────────────
126
127struct RunControl {
128    kill_tx: mpsc::Sender<KillRequest>,
129    stdin_tx: Option<mpsc::Sender<Vec<u8>>>,
130}
131
132#[derive(Debug)]
133struct KillRequest {
134    signal: i32,
135}
136
137// ─── ShimRecord ───────────────────────────────────────────────────────────────
138
139/// One JSON-line record emitted by a Tier-2 shim to the side-channel FIFO.
140///
141/// The shim (Rust `yah-log` layer or TS `@yah/log` pino transport) writes one
142/// of these per log call. Unknown keys inside `fields` pass through unchanged.
143#[cfg(unix)]
144#[derive(serde::Deserialize)]
145struct ShimRecord {
146    level: String,
147    target: String,
148    msg: String,
149    #[serde(default)]
150    fields: serde_json::Value,
151    /// Shim library name, e.g. `"yah-log-rust"`. Populates
152    /// [`EventSource::Shim::lib`].
153    #[serde(rename = "_lib", default)]
154    lib: Option<String>,
155    /// Shim library version string.
156    #[serde(rename = "_lib_ver", default)]
157    lib_version: Option<String>,
158}
159
160// ─── FdCloser ─────────────────────────────────────────────────────────────────
161
162/// RAII wrapper that closes a raw fd on drop.
163///
164/// Used to hold the write end of the log FIFO open until the lifecycle task
165/// completes. Dropping it signals EOF to the receiver thread.
166#[cfg(unix)]
167struct FdCloser(libc::c_int);
168
169#[cfg(unix)]
170impl Drop for FdCloser {
171    fn drop(&mut self) {
172        unsafe { libc::close(self.0) };
173    }
174}
175
176// SAFETY: a raw fd number is an integer; closing it from any thread is safe
177// provided we never duplicate ownership (enforced by move semantics here).
178#[cfg(unix)]
179unsafe impl Send for FdCloser {}
180
181// ─── TaskDriver ───────────────────────────────────────────────────────────────
182
183/// Manages in-flight task runs for a single camp.
184///
185/// Wrap in `Arc` to share across tasks; internal state is mutex-protected.
186pub struct TaskDriver {
187    store: Arc<TaskStore>,
188    active: Arc<Mutex<HashMap<String, RunControl>>>,
189    /// Fired with `(run_id, final_status)` after each run's lifecycle task writes
190    /// the terminal status. Used by the triage worker to detect completion.
191    completion_tx: Option<tokio::sync::mpsc::UnboundedSender<(TaskRunId, RunStatus)>>,
192}
193
194impl TaskDriver {
195    /// Create a driver backed by `store`.
196    ///
197    /// Immediately scans the store for `Running` runs left over from a prior
198    /// daemon process and marks them `Lost` ("Lost-on-disappear").
199    pub async fn new(store: Arc<TaskStore>) -> Result<Self, DriverError> {
200        Self::new_with_completion(store, None).await
201    }
202
203    /// Like `new` but also wires a completion channel. The sender fires with
204    /// `(run_id, status)` after each run's lifecycle task writes the terminal
205    /// status. Pass `Some(tx)` to drive a triage worker or other completion
206    /// listener from outside the driver.
207    pub async fn new_with_completion(
208        store: Arc<TaskStore>,
209        completion_tx: Option<tokio::sync::mpsc::UnboundedSender<(TaskRunId, RunStatus)>>,
210    ) -> Result<Self, DriverError> {
211        let stale = store.list_runs(&RunFilter {
212            status: Some("running".to_string()),
213            ..Default::default()
214        }).await?;
215        for meta in stale {
216            store.update_status(
217                &meta.id,
218                &RunStatus::Lost {
219                    reason: "daemon restarted while run was in-flight".to_string(),
220                },
221            ).await?;
222        }
223        Ok(Self {
224            store,
225            active: Arc::new(Mutex::new(HashMap::new())),
226            completion_tx,
227        })
228    }
229
230    /// Spawn `cmd` in a PTY and start capturing its output. Returns immediately
231    /// with the new [`TaskRunId`].
232    ///
233    /// A beholder is selected via `opts.beholder_select` (default `Auto`). When
234    /// a `Rewriter` beholder matches, its `adjust_argv` is applied to the
235    /// command before spawning and the diff is recorded on `beholder_status`.
236    /// When `opts.tty_attached` is `true`, `Rewriter` beholders decline in
237    /// `Auto` mode to preserve human-readable output.
238    ///
239    /// Output is written to the store as `Stream::Stdout` chunks (the PTY
240    /// kernel merges stdout and stderr). Signal handling and status updates
241    /// run in background tasks.
242    pub async fn spawn_run(&self, cmd: &str, opts: SpawnOpts) -> Result<TaskRunId, DriverError> {
243        let id = TaskRunId::new();
244        let started_at = unix_now_secs();
245        let started_at_ms: u64 = started_at.saturating_mul(1000);
246
247        // Attach a beholder (may rewrite argv and produce structured events).
248        // Resolve user drop-in directory: $YAH_BEHOLDERS_DIR or $HOME/.yah/beholders.
249        let user_dir = std::env::var_os("YAH_BEHOLDERS_DIR")
250            .map(std::path::PathBuf::from)
251            .or_else(|| {
252                std::env::var_os("HOME")
253                    .map(|h| std::path::PathBuf::from(h).join(".yah/beholders"))
254            });
255        let registry = registry_with_user_beholders(user_dir.as_deref());
256        let attach = registry.attach(cmd, &opts.beholder_select, opts.tty_attached);
257        // Use the (possibly rewritten) argv to reconstruct the effective command.
258        let effective_cmd = if attach.argv.is_empty() {
259            cmd.to_string()
260        } else {
261            attach.argv.join(" ")
262        };
263
264        self.store.insert_run(&TaskRunMeta {
265            id: id.clone(),
266            command: cmd.to_string(),
267            cwd: opts.cwd.clone(),
268            env: opts.env.clone(),
269            started_at,
270            status: RunStatus::Running,
271            label: opts.label.clone(),
272            initiator: opts.initiator.clone(),
273            beholder_status: Some(attach.status),
274            pinned: opts.pin,
275            origin: opts.origin.clone(),
276        }).await?;
277
278        // Open PTY pair.
279        let pty_sys = native_pty_system();
280        let pair = pty_sys
281            .openpty(PtySize {
282                rows: opts.pty_rows,
283                cols: opts.pty_cols,
284                pixel_width: 0,
285                pixel_height: 0,
286            })
287            .map_err(|e| DriverError::Pty(e.to_string()))?;
288
289        // Clone reader before spawning so the fd is ready immediately.
290        let pty_reader = pair
291            .master
292            .try_clone_reader()
293            .map_err(|e| DriverError::Pty(e.to_string()))?;
294
295        // Optional stdin relay: take the writer before spawning the child.
296        let stdin_tx: Option<mpsc::Sender<Vec<u8>>> = if opts.stdin_enabled {
297            let mut writer = pair
298                .master
299                .take_writer()
300                .map_err(|e| DriverError::Pty(e.to_string()))?;
301            let (tx, mut rx) = mpsc::channel::<Vec<u8>>(64);
302            task::spawn(async move {
303                use std::io::Write;
304                while let Some(bytes) = rx.recv().await {
305                    let _ = writer.write_all(&bytes);
306                    let _ = writer.flush();
307                }
308            });
309            Some(tx)
310        } else {
311            None
312        };
313
314        // ── Side-channel log FIFO (Tier 2 / yah-log shims) ──────────────────
315        //
316        // Create a named pipe (FIFO) so child processes can write structured
317        // events without touching stdout/stderr. We export its path via
318        // YAH_LOG_PIPE; no fd inheritance is involved, so portable-pty's
319        // close_random_fds() pre_exec hook doesn't interfere.
320        //
321        // The parent opens the FIFO twice:
322        //   rfd — O_RDONLY|O_NONBLOCK, then cleared to blocking → read events
323        //   wfd — O_WRONLY (wrapped in FdCloser) → keeps the FIFO alive until
324        //          the lifecycle task drops it (after run completion), producing
325        //          EOF for the receiver thread.
326        #[cfg(unix)]
327        let log_fifo: Option<(libc::c_int, FdCloser, std::path::PathBuf)> = if opts.log_fd_enabled {
328            let fifo_path = std::env::temp_dir().join(format!("yah-log-{}.fifo", id));
329            let path_cstr = match std::ffi::CString::new(fifo_path.to_string_lossy().as_bytes()) {
330                Ok(s) => s,
331                Err(_) => {
332                    // Path contained a nul byte — extremely unlikely; skip FIFO.
333                    return Err(DriverError::Io(std::io::Error::new(
334                        std::io::ErrorKind::InvalidInput,
335                        "log FIFO path contained nul byte",
336                    )));
337                }
338            };
339            let mkfifo_ret = unsafe { libc::mkfifo(path_cstr.as_ptr(), 0o600) };
340            if mkfifo_ret != 0 {
341                None // FIFO creation failed; continue without side-channel
342            } else {
343                // Open read end without blocking (no writer yet).
344                let rfd = unsafe {
345                    libc::open(path_cstr.as_ptr(), libc::O_RDONLY | libc::O_NONBLOCK)
346                };
347                if rfd < 0 {
348                    let _ = unsafe { libc::unlink(path_cstr.as_ptr()) };
349                    None
350                } else {
351                    // Switch read end to blocking so reads yield proper data.
352                    unsafe { libc::fcntl(rfd, libc::F_SETFL, 0) };
353                    // Open write end — this succeeds immediately because rfd is open.
354                    let wfd = unsafe {
355                        libc::open(path_cstr.as_ptr(), libc::O_WRONLY)
356                    };
357                    if wfd < 0 {
358                        unsafe { libc::close(rfd) };
359                        let _ = unsafe { libc::unlink(path_cstr.as_ptr()) };
360                        None
361                    } else {
362                        Some((rfd, FdCloser(wfd), fifo_path))
363                    }
364                }
365            }
366        } else {
367            None
368        };
369
370        // Build and spawn the child inside the slave.
371        let mut cb = CommandBuilder::new("sh");
372        cb.args(["-c", &effective_cmd]);
373        cb.cwd(&opts.cwd);
374        for (k, v) in &opts.env {
375            cb.env(k, v);
376        }
377        cb.env("TERM", "xterm-256color");
378
379        // Export YAH_TASK_RUN and YAH_LOG_PIPE if the FIFO was created.
380        #[cfg(unix)]
381        if let Some((_, _, ref fifo_path)) = log_fifo {
382            cb.env("YAH_TASK_RUN", id.to_string());
383            cb.env("YAH_LOG_PIPE", fifo_path.to_string_lossy().as_ref());
384        }
385
386        let child = pair
387            .slave
388            .spawn_command(cb)
389            .map_err(|e| DriverError::Pty(e.to_string()))?;
390        // Drop the parent's slave handle so EOF propagates once the child exits.
391        drop(pair.slave);
392
393        let pid = child.process_id().unwrap_or(0);
394
395        // ── FIFO: launch receiver thread; pass write-end holder to lifecycle ──
396        //
397        // The receiver thread reads until EOF. EOF arrives when ALL write-end
398        // holders close: the child's own writers (when it exits) plus the
399        // FdCloser we hand to the lifecycle task (which drops it after writing
400        // the terminal RunStatus). Events written before the last close are
401        // still drained by the receiver thread before it exits.
402        #[cfg(unix)]
403        let log_wfd_holder: Option<FdCloser> = if let Some((rfd, wfd, fifo_path)) = log_fifo {
404            let store_log = Arc::clone(&self.store);
405            let id_log = id.clone();
406            let rt = tokio::runtime::Handle::current();
407            // spawn_blocking: lets the runtime track this thread so the
408            // Handle::block_on calls inside have a worker to drive futures.
409            tokio::task::spawn_blocking(move || {
410                run_log_receiver(rt, store_log, id_log, rfd, fifo_path, started_at_ms);
411            });
412            Some(wfd)
413        } else {
414            None
415        };
416
417        // Channels.
418        let (kill_tx, kill_rx) = mpsc::channel::<KillRequest>(4);
419        let (reader_done_tx, reader_done_rx) = oneshot::channel::<()>();
420
421        // Reader thread: PTY output → store chunks → beholder events.
422        // Runs on a dedicated OS thread because PTY reads are blocking.
423        {
424            let store_r = Arc::clone(&self.store);
425            let id_r = id.clone();
426            let mut beholder = attach.beholder;
427            let rt = tokio::runtime::Handle::current();
428            tokio::task::spawn_blocking(move || {
429                let mut buf = [0u8; READ_BUF_SIZE];
430                let mut reader = pty_reader;
431                loop {
432                    match reader.read(&mut buf) {
433                        Ok(0) | Err(_) => break,
434                        Ok(n) => {
435                            let offset = elapsed_ms(started_at_ms);
436                            let append_res = rt.block_on(store_r.append_chunk(
437                                &id_r,
438                                offset,
439                                Stream::Stdout,
440                                &buf[..n],
441                            ));
442                            if let Ok(seq) = append_res {
443                                let mut detach_beholder = false;
444                                if let Some(ref mut b) = beholder {
445                                    let chunk = OutputChunk {
446                                        run_id: id_r.clone(),
447                                        seq,
448                                        offset_ms: offset,
449                                        stream: Stream::Stdout,
450                                        bytes: buf[..n].to_vec(),
451                                    };
452                                    for ev in b.parse_chunk(&chunk) {
453                                        let _ = rt.block_on(store_r.append_event(
454                                            &ev.run_id,
455                                            ev.offset_ms,
456                                            ev.level,
457                                            &ev.target,
458                                            &ev.msg,
459                                            &ev.fields,
460                                            ev.anchor.as_ref().map(|a| a.seq),
461                                            &ev.source,
462                                        ));
463                                    }
464                                    if let Some(reason) = b.unknown_format_reason() {
465                                        let new_status = BeholderStatus::unknown_format_with_reason(
466                                            b.name(),
467                                            reason,
468                                        );
469                                        let _ = rt.block_on(
470                                            store_r.update_beholder_status(&id_r, &new_status),
471                                        );
472                                        detach_beholder = true;
473                                    }
474                                }
475                                if detach_beholder {
476                                    beholder = None;
477                                }
478                            }
479                        }
480                    }
481                }
482                if let Some(ref mut b) = beholder {
483                    let final_offset = elapsed_ms(started_at_ms);
484                    for ev in b.on_done(&id_r, final_offset) {
485                        let _ = rt.block_on(store_r.append_event(
486                            &ev.run_id,
487                            ev.offset_ms,
488                            ev.level,
489                            &ev.target,
490                            &ev.msg,
491                            &ev.fields,
492                            ev.anchor.as_ref().map(|a| a.seq),
493                            &ev.source,
494                        ));
495                    }
496                    if let Some(reason) = b.unknown_format_reason() {
497                        let new_status = BeholderStatus::unknown_format_with_reason(b.name(), reason);
498                        let _ = rt.block_on(store_r.update_beholder_status(&id_r, &new_status));
499                    }
500                }
501                let _ = reader_done_tx.send(());
502            });
503        }
504
505        // Lifecycle task: monitor kill requests, wait for exit, update status.
506        // The task also holds the log FIFO write-end closer (if any) so that
507        // EOF propagates to the receiver thread after RunStatus is written.
508        {
509            let store_l = Arc::clone(&self.store);
510            let active_l = Arc::clone(&self.active);
511            let id_l = id.clone();
512            let master = pair.master;
513            let completion_tx_l = self.completion_tx.clone();
514            #[cfg(unix)]
515            let wfd_l = log_wfd_holder;
516            task::spawn(async move {
517                run_lifecycle(
518                    store_l,
519                    active_l,
520                    id_l,
521                    pid,
522                    child,
523                    master,
524                    kill_rx,
525                    reader_done_rx,
526                    completion_tx_l,
527                    #[cfg(unix)]
528                    wfd_l,
529                )
530                .await;
531            });
532        }
533
534        self.active
535            .lock()
536            .unwrap()
537            .insert(id.to_string(), RunControl { kill_tx, stdin_tx });
538
539        Ok(id)
540    }
541
542    /// Send `signal` to a running task. Defaults to SIGTERM (15).
543    ///
544    /// For SIGTERM, the driver waits up to 5 seconds for the process to exit
545    /// before escalating to SIGKILL. Returns `DriverError::NotFound` if the
546    /// run is not active (already exited or launched on a different driver
547    /// instance).
548    pub async fn kill_run(&self, id: &TaskRunId, signal: Option<i32>) -> Result<(), DriverError> {
549        let kill_tx = self
550            .active
551            .lock()
552            .unwrap()
553            .get(&id.to_string())
554            .map(|c| c.kill_tx.clone());
555
556        match kill_tx {
557            Some(tx) => tx
558                .send(KillRequest { signal: signal.unwrap_or(SIGTERM) })
559                .await
560                .map_err(|_| DriverError::NotFound(id.to_string())),
561            None => Err(DriverError::NotFound(id.to_string())),
562        }
563    }
564
565    /// Write bytes to the stdin of a running task (requires `stdin_enabled`).
566    pub async fn send_stdin(&self, id: &TaskRunId, bytes: Vec<u8>) -> Result<(), DriverError> {
567        let stdin_tx = self
568            .active
569            .lock()
570            .unwrap()
571            .get(&id.to_string())
572            .and_then(|c| c.stdin_tx.clone());
573
574        match stdin_tx {
575            Some(tx) => tx
576                .send(bytes)
577                .await
578                .map_err(|_| DriverError::NotFound(id.to_string())),
579            None => Err(DriverError::NotFound(id.to_string())),
580        }
581    }
582}
583
584// ─── Log fd receiver ─────────────────────────────────────────────────────────
585
586/// Read JSON-lines from the side-channel FIFO read end and store them as
587/// [`EventSource::Shim`] events.
588///
589/// Runs on a dedicated OS thread; exits when the read end sees EOF. EOF
590/// arrives after both the child process AND the lifecycle task have closed
591/// their write ends of the FIFO. The FIFO file is deleted on exit.
592#[cfg(unix)]
593fn run_log_receiver(
594    rt: tokio::runtime::Handle,
595    store: Arc<TaskStore>,
596    run_id: TaskRunId,
597    read_fd: libc::c_int,
598    fifo_path: std::path::PathBuf,
599    started_at_ms: u64,
600) {
601    use std::io::BufRead;
602    use std::os::unix::io::FromRawFd;
603
604    // SAFETY: `read_fd` is a valid, open FIFO fd handed exclusively to this
605    // thread. `File` takes ownership and closes the fd on drop.
606    let file = unsafe { std::fs::File::from_raw_fd(read_fd) };
607    let reader = std::io::BufReader::new(file);
608
609    for line in reader.lines() {
610        let line = match line {
611            Ok(l) => l,
612            Err(_) => break,
613        };
614        let trimmed = line.trim();
615        if trimmed.is_empty() {
616            continue;
617        }
618        let rec: ShimRecord = match serde_json::from_str(trimmed) {
619            Ok(r) => r,
620            Err(_) => continue, // skip malformed lines silently
621        };
622        let level = rec.level.parse::<crate::types::Level>().unwrap_or(crate::types::Level::Info);
623        let source = crate::types::EventSource::Shim {
624            lib: rec.lib.unwrap_or_else(|| "unknown".to_string()),
625            version: rec.lib_version.unwrap_or_else(|| "0.0.0".to_string()),
626        };
627        let fields = if rec.fields.is_object() {
628            rec.fields
629        } else {
630            serde_json::Value::Object(Default::default())
631        };
632        let offset = elapsed_ms(started_at_ms);
633        let _ = rt.block_on(store.append_event(
634            &run_id,
635            offset,
636            level,
637            &rec.target,
638            &rec.msg,
639            &fields,
640            None,
641            &source,
642        ));
643    }
644
645    // Clean up the FIFO file now that the receiver has drained.
646    let _ = std::fs::remove_file(&fifo_path);
647}
648
649// ─── Lifecycle task ───────────────────────────────────────────────────────────
650
651async fn run_lifecycle(
652    store: Arc<TaskStore>,
653    active: Arc<Mutex<HashMap<String, RunControl>>>,
654    id: TaskRunId,
655    pid: u32,
656    child: Box<dyn portable_pty::Child + Send>,
657    master: Box<dyn portable_pty::MasterPty + Send>,
658    mut kill_rx: mpsc::Receiver<KillRequest>,
659    reader_done_rx: oneshot::Receiver<()>,
660    completion_tx: Option<tokio::sync::mpsc::UnboundedSender<(TaskRunId, RunStatus)>>,
661    // Holds the write end of the log FIFO open until this task completes.
662    // Dropping it produces EOF for the receiver thread, which happens after
663    // the terminal RunStatus is written below.
664    #[cfg(unix)]
665    _log_wfd: Option<FdCloser>,
666) {
667    // Pin the reader-done future so it can be polled by reference in
668    // nested select! arms without consuming ownership.
669    let reader_done = async { reader_done_rx.await.ok(); };
670    tokio::pin!(reader_done);
671
672    let sent_signal: Option<i32>;
673
674    tokio::select! {
675        req = kill_rx.recv() => {
676            match req {
677                Some(KillRequest { signal }) => {
678                    send_unix_signal(pid, signal);
679                    if signal == SIGKILL {
680                        sent_signal = Some(SIGKILL);
681                    } else {
682                        // Grace period: give the process a chance to exit cleanly.
683                        tokio::select! {
684                            _ = &mut reader_done => {
685                                // Exited within grace — no SIGKILL needed.
686                                sent_signal = Some(signal);
687                            }
688                            _ = tokio::time::sleep(DEFAULT_GRACE) => {
689                                // Grace expired — escalate.
690                                send_unix_signal(pid, SIGKILL);
691                                sent_signal = Some(SIGKILL);
692                            }
693                        }
694                    }
695                }
696                // kill_tx dropped (driver shutting down) — force kill.
697                None => {
698                    send_unix_signal(pid, SIGKILL);
699                    sent_signal = Some(SIGKILL);
700                }
701            }
702        }
703        _ = &mut reader_done => {
704            sent_signal = None;
705        }
706    }
707
708    // Reap the child (blocking) on a dedicated thread-pool slot.
709    // Move master in here so the PTY fd outlives the wait.
710    let exit_code = task::spawn_blocking(move || {
711        let mut c = child;
712        let _m = master; // dropped after wait() returns
713        c.wait().ok().map(|s| s.exit_code())
714    })
715    .await
716    .ok()
717    .flatten();
718
719    let ended_at = unix_now_secs();
720    let status = match sent_signal {
721        Some(sig) => RunStatus::Killed { signal: sig, ended_at },
722        None => match exit_code {
723            Some(code) => RunStatus::Done { exit_code: code as i32, ended_at },
724            None => RunStatus::Lost {
725                reason: "process exited without an exit code".to_string(),
726            },
727        },
728    };
729
730    let _ = store.update_status(&id, &status).await;
731    if let Some(ref tx) = completion_tx {
732        let _ = tx.send((id.clone(), status));
733    }
734    active.lock().unwrap().remove(&id.to_string());
735}
736
737// ─── Helpers ──────────────────────────────────────────────────────────────────
738
739fn send_unix_signal(pid: u32, signal: i32) {
740    #[cfg(unix)]
741    unsafe {
742        libc::kill(pid as libc::pid_t, signal);
743    }
744    // On non-Unix platforms signal delivery is not implemented here.
745}
746
747fn unix_now_secs() -> u64 {
748    SystemTime::now()
749        .duration_since(UNIX_EPOCH)
750        .unwrap_or_default()
751        .as_secs()
752}
753
754fn elapsed_ms(started_at_ms: u64) -> u32 {
755    let now_ms = SystemTime::now()
756        .duration_since(UNIX_EPOCH)
757        .unwrap_or_default()
758        .as_millis() as u64;
759    now_ms.saturating_sub(started_at_ms).min(u32::MAX as u64) as u32
760}
761
762// ─── Tests ────────────────────────────────────────────────────────────────────
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767    use crate::store::ChunkFilter;
768
769    async fn open_store(dir: &tempfile::TempDir) -> Arc<TaskStore> {
770        Arc::new(TaskStore::open(&dir.path().join("tr.turso")).await.unwrap())
771    }
772
773    // ── Lost-on-disappear (pure store, no PTY) ────────────────────────────────
774
775    #[tokio::test]
776    async fn lost_on_disappear_marks_stale_running_runs() {
777        let dir = tempfile::tempdir().unwrap();
778        let store = open_store(&dir).await;
779
780        // Simulate a run left in "Running" state by a prior daemon.
781        let stale_id = TaskRunId::new();
782        store
783            .insert_run(&TaskRunMeta {
784                id: stale_id.clone(),
785                command: "sleep 9999".to_string(),
786                cwd: "/tmp".into(),
787                env: vec![],
788                started_at: unix_now_secs() - 60,
789                status: RunStatus::Running,
790                label: None,
791                initiator: Initiator::Human { camp: "test".to_string() },
792                beholder_status: None,
793                pinned: false,
794                origin: None,
795            })
796            .await
797            .unwrap();
798
799        // Creating a new driver must mark stale runs Lost.
800        let _driver = TaskDriver::new(Arc::clone(&store)).await.unwrap();
801
802        let meta = store.get_run(&stale_id).await.unwrap().unwrap();
803        assert!(
804            matches!(meta.status, RunStatus::Lost { .. }),
805            "stale run should be Lost, got {:?}",
806            meta.status
807        );
808    }
809
810    #[tokio::test]
811    async fn new_driver_does_not_touch_completed_runs() {
812        let dir = tempfile::tempdir().unwrap();
813        let store = open_store(&dir).await;
814
815        let done_id = TaskRunId::new();
816        store
817            .insert_run(&TaskRunMeta {
818                id: done_id.clone(),
819                command: "true".to_string(),
820                cwd: "/tmp".into(),
821                env: vec![],
822                started_at: unix_now_secs() - 10,
823                status: RunStatus::Running,
824                label: None,
825                initiator: Initiator::Human { camp: "test".to_string() },
826                beholder_status: None,
827                pinned: false,
828                origin: None,
829            })
830            .await
831            .unwrap();
832        store
833            .update_status(&done_id, &RunStatus::Done { exit_code: 0, ended_at: unix_now_secs() })
834            .await
835            .unwrap();
836
837        let _driver = TaskDriver::new(Arc::clone(&store)).await.unwrap();
838
839        let meta = store.get_run(&done_id).await.unwrap().unwrap();
840        assert!(
841            matches!(meta.status, RunStatus::Done { .. }),
842            "completed run must not be touched"
843        );
844    }
845
846    // ── PTY spawn + capture ───────────────────────────────────────────────────
847
848    #[tokio::test]
849    async fn spawn_echo_and_read_chunks() {
850        let dir = tempfile::tempdir().unwrap();
851        let store = open_store(&dir).await;
852        let driver = TaskDriver::new(Arc::clone(&store)).await.unwrap();
853
854        let id = driver
855            .spawn_run(
856                "echo hello_world",
857                SpawnOpts { cwd: "/tmp".into(), ..Default::default() },
858            )
859            .await
860            .unwrap();
861
862        // Wait for the run to complete (poll status up to 5 s).
863        let deadline = std::time::Instant::now() + Duration::from_secs(5);
864        loop {
865            let meta = store.get_run(&id).await.unwrap().unwrap();
866            if matches!(meta.status, RunStatus::Done { .. } | RunStatus::Lost { .. }) {
867                break;
868            }
869            if std::time::Instant::now() > deadline {
870                panic!("run did not complete in time, status={:?}", meta.status);
871            }
872            tokio::time::sleep(Duration::from_millis(50)).await;
873        }
874
875        // Chunks must contain "hello_world".
876        let chunks = store
877            .get_chunks(&id, &ChunkFilter::default())
878            .await
879            .unwrap();
880        let output: Vec<u8> = chunks.into_iter().flat_map(|c| c.bytes).collect();
881        let text = String::from_utf8_lossy(&output);
882        assert!(
883            text.contains("hello_world"),
884            "expected 'hello_world' in output, got: {text:?}"
885        );
886
887        let meta = store.get_run(&id).await.unwrap().unwrap();
888        assert!(
889            matches!(meta.status, RunStatus::Done { exit_code: 0, .. }),
890            "expected Done(0), got {:?}",
891            meta.status
892        );
893    }
894
895    #[tokio::test]
896    async fn spawn_failing_command_records_nonzero_exit() {
897        let dir = tempfile::tempdir().unwrap();
898        let store = open_store(&dir).await;
899        let driver = TaskDriver::new(Arc::clone(&store)).await.unwrap();
900
901        let id = driver
902            .spawn_run(
903                "exit 42",
904                SpawnOpts { cwd: "/tmp".into(), ..Default::default() },
905            )
906            .await
907            .unwrap();
908
909        let deadline = std::time::Instant::now() + Duration::from_secs(5);
910        loop {
911            let meta = store.get_run(&id).await.unwrap().unwrap();
912            if !matches!(meta.status, RunStatus::Running | RunStatus::Pending) {
913                match meta.status {
914                    RunStatus::Done { exit_code, .. } => {
915                        assert_ne!(exit_code, 0, "exit 42 should produce a non-zero exit code");
916                    }
917                    other => panic!("unexpected status: {other:?}"),
918                }
919                break;
920            }
921            if std::time::Instant::now() > deadline {
922                panic!("run did not complete in time");
923            }
924            tokio::time::sleep(Duration::from_millis(50)).await;
925        }
926    }
927
928    // ── Signal handling ───────────────────────────────────────────────────────
929
930    #[cfg(unix)]
931    #[tokio::test]
932    async fn kill_with_sigterm_transitions_to_killed() {
933        let dir = tempfile::tempdir().unwrap();
934        let store = open_store(&dir).await;
935        let driver = Arc::new(TaskDriver::new(Arc::clone(&store)).await.unwrap());
936
937        let id = driver
938            .spawn_run(
939                "sleep 60",
940                SpawnOpts { cwd: "/tmp".into(), ..Default::default() },
941            )
942            .await
943            .unwrap();
944
945        // Give the process a moment to start.
946        tokio::time::sleep(Duration::from_millis(100)).await;
947
948        driver.kill_run(&id, Some(SIGTERM)).await.unwrap();
949
950        let deadline = std::time::Instant::now() + Duration::from_secs(10);
951        loop {
952            let meta = store.get_run(&id).await.unwrap().unwrap();
953            if matches!(meta.status, RunStatus::Killed { .. } | RunStatus::Lost { .. }) {
954                assert!(
955                    matches!(meta.status, RunStatus::Killed { .. }),
956                    "expected Killed, got {:?}",
957                    meta.status
958                );
959                break;
960            }
961            if std::time::Instant::now() > deadline {
962                panic!("run did not become Killed in time, status={:?}", meta.status);
963            }
964            tokio::time::sleep(Duration::from_millis(50)).await;
965        }
966    }
967
968    #[cfg(unix)]
969    #[tokio::test]
970    async fn kill_run_returns_not_found_after_exit() {
971        let dir = tempfile::tempdir().unwrap();
972        let store = open_store(&dir).await;
973        let driver = Arc::new(TaskDriver::new(Arc::clone(&store)).await.unwrap());
974
975        let id = driver
976            .spawn_run(
977                "echo done",
978                SpawnOpts { cwd: "/tmp".into(), ..Default::default() },
979            )
980            .await
981            .unwrap();
982
983        // Wait for natural exit.
984        let deadline = std::time::Instant::now() + Duration::from_secs(5);
985        loop {
986            let meta = store.get_run(&id).await.unwrap().unwrap();
987            if !matches!(meta.status, RunStatus::Running | RunStatus::Pending) {
988                break;
989            }
990            if std::time::Instant::now() > deadline {
991                panic!("run did not complete");
992            }
993            tokio::time::sleep(Duration::from_millis(50)).await;
994        }
995
996        // Kill on a completed run should return NotFound.
997        let result = driver.kill_run(&id, None).await;
998        assert!(
999            matches!(result, Err(DriverError::NotFound(_))),
1000            "expected NotFound, got {result:?}"
1001        );
1002    }
1003
1004    // ── Stdin relay ───────────────────────────────────────────────────────────
1005
1006    #[cfg(unix)]
1007    #[tokio::test]
1008    async fn stdin_send_reaches_child() {
1009        let dir = tempfile::tempdir().unwrap();
1010        let store = open_store(&dir).await;
1011        let driver = Arc::new(TaskDriver::new(Arc::clone(&store)).await.unwrap());
1012
1013        // Shell that reads a line from stdin and echoes it back.
1014        let id = driver
1015            .spawn_run(
1016                "read line && echo got_$line",
1017                SpawnOpts {
1018                    cwd: "/tmp".into(),
1019                    stdin_enabled: true,
1020                    ..Default::default()
1021                },
1022            )
1023            .await
1024            .unwrap();
1025
1026        tokio::time::sleep(Duration::from_millis(150)).await;
1027        driver.send_stdin(&id, b"hello\n".to_vec()).await.unwrap();
1028
1029        let deadline = std::time::Instant::now() + Duration::from_secs(5);
1030        loop {
1031            let meta = store.get_run(&id).await.unwrap().unwrap();
1032            if !matches!(meta.status, RunStatus::Running | RunStatus::Pending) {
1033                break;
1034            }
1035            if std::time::Instant::now() > deadline {
1036                panic!("run did not complete after stdin input");
1037            }
1038            tokio::time::sleep(Duration::from_millis(50)).await;
1039        }
1040
1041        let chunks = store.get_chunks(&id, &ChunkFilter::default()).await.unwrap();
1042        let raw: Vec<u8> = chunks.into_iter().flat_map(|c| c.bytes).collect();
1043        let text = String::from_utf8_lossy(&raw);
1044        assert!(
1045            text.contains("got_hello"),
1046            "expected 'got_hello' in output, got: {text:?}"
1047        );
1048    }
1049
1050    // ── Tier-2 side-channel log fd ────────────────────────────────────────────
1051
1052    /// Verify that a child writing a JSON-line to `YAH_LOG_PIPE` (via
1053    /// `printf ... >> $YAH_LOG_PIPE`) produces a shim event with the correct
1054    /// fields in the store.
1055    ///
1056    /// The child opens the FIFO path for writing — no fd inheritance needed.
1057    #[cfg(unix)]
1058    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1059    async fn log_pipe_events_land_in_store() {
1060        use crate::store::EventFilter;
1061
1062        let dir = tempfile::tempdir().unwrap();
1063        let store = open_store(&dir).await;
1064        let driver = Arc::new(TaskDriver::new(Arc::clone(&store)).await.unwrap());
1065
1066        // The shell writes one JSON-line to the FIFO by redirecting printf
1067        // output to the path stored in YAH_LOG_PIPE.
1068        let cmd = r#"printf '{"level":"warn","target":"test.shim","msg":"hello-from-pipe","fields":{"x":42},"_lib":"test-shim","_lib_ver":"0.1.0"}\n' >> "$YAH_LOG_PIPE""#;
1069
1070        let id = driver
1071            .spawn_run(cmd, SpawnOpts { cwd: "/tmp".into(), ..Default::default() })
1072            .await
1073            .unwrap();
1074
1075        // Wait for run completion. Deadline is generous because parallel-test
1076        // load + the rt.block_on hops from the reader/log threads can slow
1077        // child-process scheduling.
1078        let deadline = std::time::Instant::now() + Duration::from_secs(20);
1079        loop {
1080            let meta = store.get_run(&id).await.unwrap().unwrap();
1081            if matches!(meta.status, RunStatus::Done { .. } | RunStatus::Lost { .. }) {
1082                break;
1083            }
1084            if std::time::Instant::now() > deadline {
1085                panic!("run did not complete in time");
1086            }
1087            tokio::time::sleep(Duration::from_millis(50)).await;
1088        }
1089
1090        // The log receiver thread drains after the lifecycle task drops the
1091        // write-end FdCloser; give it a brief moment.
1092        tokio::time::sleep(Duration::from_millis(500)).await;
1093
1094        let events = store.query_events(&id, &EventFilter::default()).await.unwrap();
1095        assert!(
1096            !events.is_empty(),
1097            "expected at least one shim event, got none"
1098        );
1099        let ev = events.iter().find(|e| e.target == "test.shim");
1100        let ev = ev.expect("event with target 'test.shim' not found");
1101        assert_eq!(ev.msg, "hello-from-pipe");
1102        assert_eq!(ev.level, crate::types::Level::Warn);
1103        assert!(
1104            matches!(&ev.source, crate::types::EventSource::Shim { lib, .. } if lib == "test-shim"),
1105            "unexpected source: {:?}",
1106            ev.source
1107        );
1108        assert_eq!(ev.fields.get("x"), Some(&serde_json::json!(42)));
1109    }
1110
1111    /// When `log_fd_enabled` is false, neither `YAH_TASK_RUN` nor
1112    /// `YAH_LOG_PIPE` are exported, and no shim events are written.
1113    #[cfg(unix)]
1114    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1115    async fn log_pipe_disabled_produces_no_events() {
1116        use crate::store::EventFilter;
1117
1118        let dir = tempfile::tempdir().unwrap();
1119        let store = open_store(&dir).await;
1120        let driver = Arc::new(TaskDriver::new(Arc::clone(&store)).await.unwrap());
1121
1122        // Try to write to YAH_LOG_PIPE; the conditional guards against
1123        // the variable being absent, so the command always exits 0.
1124        let cmd = r#"[ -n "$YAH_LOG_PIPE" ] && printf '{"level":"info","target":"t","msg":"m","fields":{}}\n' >> "$YAH_LOG_PIPE" || true"#;
1125
1126        let id = driver
1127            .spawn_run(
1128                cmd,
1129                SpawnOpts { cwd: "/tmp".into(), log_fd_enabled: false, ..Default::default() },
1130            )
1131            .await
1132            .unwrap();
1133
1134        let deadline = std::time::Instant::now() + Duration::from_secs(5);
1135        loop {
1136            let meta = store.get_run(&id).await.unwrap().unwrap();
1137            if matches!(meta.status, RunStatus::Done { .. } | RunStatus::Lost { .. }) {
1138                break;
1139            }
1140            if std::time::Instant::now() > deadline {
1141                panic!("run did not complete");
1142            }
1143            tokio::time::sleep(Duration::from_millis(50)).await;
1144        }
1145
1146        tokio::time::sleep(Duration::from_millis(100)).await;
1147
1148        let events = store.query_events(&id, &EventFilter::default()).await.unwrap();
1149        assert!(
1150            events.is_empty(),
1151            "expected no shim events when log_fd_enabled=false, got {}",
1152            events.len()
1153        );
1154    }
1155}