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