Skip to main content

asched_core/routine/
execution.rs

1use super::store::RoutineStore;
2use super::{Routine, RoutineError, RunCause, RunRecord, RunStatus, MAX_RUNS};
3use serde_json::Value;
4use std::fs::{self, File};
5use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
6use std::os::fd::AsRawFd;
7use std::os::unix::fs::PermissionsExt;
8use std::os::unix::process::CommandExt;
9use std::process::{Command, Stdio};
10use std::thread;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13const FINAL_OUTPUT_BYTES: usize = 16 * 1024;
14const STRUCTURED_LINE_BYTES: usize = 64 * 1024;
15const MAX_RUN_LOG_BYTES: usize = 8 * 1024 * 1024;
16const RUN_LOG_TAIL_BYTES: usize = 128 * 1024;
17const TRUNCATION_MARKER: &[u8] = b"\n[asched: middle output truncated]\n";
18
19pub fn expanded_argv(routine: &Routine) -> (Vec<String>, Option<&[u8]>) {
20    if routine.command.iter().any(|arg| arg == "{prompt}") {
21        (
22            routine
23                .command
24                .iter()
25                .map(|arg| {
26                    if arg == "{prompt}" {
27                        routine.prompt.clone()
28                    } else {
29                        arg.clone()
30                    }
31                })
32                .collect(),
33            None,
34        )
35    } else {
36        (routine.command.clone(), Some(routine.prompt.as_bytes()))
37    }
38}
39
40pub fn execute(
41    store: &RoutineStore,
42    routine: &Routine,
43    scheduled: Option<i64>,
44) -> Result<RunRecord, RoutineError> {
45    execute_supervised(store, routine, scheduled, |_| {})
46}
47
48pub fn execute_supervised(
49    store: &RoutineStore,
50    routine: &Routine,
51    scheduled: Option<i64>,
52    on_started: impl FnOnce(&RunRecord),
53) -> Result<RunRecord, RoutineError> {
54    if routine.command.is_empty() {
55        return Err(RoutineError::Validation(
56            "command must contain at least one argv item".into(),
57        ));
58    }
59    let cause = scheduled.map_or(RunCause::Manual, |scheduled_epoch_minute| RunCause::Cron {
60        scheduled_epoch_minute,
61    });
62    let record = prepare_run_record(store, routine, cause);
63    append_record(store, record.clone())?;
64    execute_prepared_supervised(store, routine, record, None, on_started)
65}
66
67pub(crate) fn prepare_run_record(
68    store: &RoutineStore,
69    routine: &Routine,
70    cause: RunCause,
71) -> RunRecord {
72    let started = now_epoch();
73    let id = format!("{}-{}", started, now_nanos());
74    let run_dir = store.logs_dir().join(hex_name(&routine.name)).join(&id);
75    let scheduled_epoch_minute = match cause {
76        RunCause::Cron {
77            scheduled_epoch_minute,
78        } => Some(scheduled_epoch_minute),
79        _ => None,
80    };
81    RunRecord {
82        id,
83        routine: routine.name.clone(),
84        started_epoch: started,
85        finished_epoch: None,
86        cause,
87        scheduled_epoch_minute,
88        status: RunStatus::Running,
89        exit_code: None,
90        pid: None,
91        process_start: None,
92        final_output: String::new(),
93        stdout_path: run_dir.join("stdout.log"),
94        stderr_path: run_dir.join("stderr.log"),
95    }
96}
97
98pub(crate) fn execute_prepared_supervised(
99    store: &RoutineStore,
100    routine: &Routine,
101    mut record: RunRecord,
102    event_payload: Option<String>,
103    on_started: impl FnOnce(&RunRecord),
104) -> Result<RunRecord, RoutineError> {
105    if routine.command.is_empty() {
106        return Err(RoutineError::Validation(
107            "command argv must not be empty".into(),
108        ));
109    }
110    let lock_dir = store.logs_dir().join("locks");
111    fs::create_dir_all(&lock_dir)?;
112    fs::set_permissions(&lock_dir, fs::Permissions::from_mode(0o700))?;
113    let run_lock = std::fs::OpenOptions::new()
114        .read(true)
115        .write(true)
116        .create(true)
117        .truncate(false)
118        .open(lock_dir.join(hex_name(&routine.name)))?;
119    run_lock.set_permissions(fs::Permissions::from_mode(0o600))?;
120    if unsafe { libc::flock(run_lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
121        return Err(RoutineError::AlreadyRunning(routine.name.clone()));
122    }
123    let run_dir = record
124        .stdout_path
125        .parent()
126        .ok_or_else(|| RoutineError::Corrupt("run output path has no parent".into()))?;
127    fs::create_dir_all(run_dir)?;
128    fs::set_permissions(run_dir, fs::Permissions::from_mode(0o700))?;
129    let stdout_path = record.stdout_path.clone();
130    let stderr_path = record.stderr_path.clone();
131
132    let (argv, stdin_bytes) = expanded_argv(routine);
133    let mut command = Command::new(&argv[0]);
134    command.args(&argv[1..]).current_dir(store.project());
135    if let Some(payload) = event_payload {
136        command.env("ASCHED_EVENT_PAYLOAD", payload);
137    }
138    command
139        .stdin(Stdio::piped())
140        .stdout(Stdio::piped())
141        .stderr(Stdio::piped());
142    command.process_group(0);
143    let mut child = match command.spawn() {
144        Ok(child) => child,
145        Err(error) => {
146            let stdout_file = File::create(&stdout_path)?;
147            stdout_file.set_permissions(fs::Permissions::from_mode(0o600))?;
148            let mut stderr_file = File::create(&stderr_path)?;
149            stderr_file.set_permissions(fs::Permissions::from_mode(0o600))?;
150            stderr_file.write_all(error.to_string().as_bytes())?;
151            stderr_file.sync_all()?;
152            record.status = RunStatus::SpawnFailed;
153            record.finished_epoch = Some(now_epoch());
154            record.final_output = error.to_string();
155            replace_record(store, record.clone())?;
156            return Ok(record);
157        }
158    };
159    record.pid = Some(child.id() as i32);
160    record.process_start = process_start(child.id() as i32);
161    if let Err(error) = replace_record(store, record.clone()) {
162        return fail_started_run(
163            store,
164            child,
165            record,
166            &format!("failed to persist spawned process: {error}"),
167            None,
168            None,
169        );
170    }
171    on_started(&record);
172    let stdout = match child.stdout.take() {
173        Some(stdout) => stdout,
174        None => return fail_started_run(store, child, record, "stdout pipe missing", None, None),
175    };
176    let stderr = match child.stderr.take() {
177        Some(stderr) => stderr,
178        None => return fail_started_run(store, child, record, "stderr pipe missing", None, None),
179    };
180    let out_thread = drain(stdout, stdout_path.clone());
181    let err_thread = drain(stderr, stderr_path.clone());
182    if let Some(mut stdin) = child.stdin.take() {
183        if let Some(bytes) = stdin_bytes {
184            if let Err(error) = stdin.write_all(bytes) {
185                return fail_started_run(
186                    store,
187                    child,
188                    record,
189                    &format!("prompt delivery failed: {error}"),
190                    Some(out_thread),
191                    Some(err_thread),
192                );
193            }
194        }
195    }
196    let pid = child.id() as i32;
197    let status = child.wait()?;
198    terminate_remaining_process_group(pid);
199    out_thread
200        .join()
201        .map_err(|_| RoutineError::Io("stdout reader panicked".into()))??;
202    err_thread
203        .join()
204        .map_err(|_| RoutineError::Io("stderr reader panicked".into()))??;
205    record.finished_epoch = Some(now_epoch());
206    record.exit_code = status.code();
207    let cancelled = store
208        .load_runtime()
209        .ok()
210        .and_then(|state| {
211            state
212                .runs
213                .get(&routine.name)
214                .and_then(|runs| runs.iter().find(|run| run.id == record.id))
215                .cloned()
216        })
217        .is_some_and(|run| run.status == RunStatus::Cancelled);
218    record.status = if cancelled {
219        RunStatus::Cancelled
220    } else if status.success() {
221        RunStatus::Succeeded
222    } else {
223        RunStatus::Failed
224    };
225    record.pid = None;
226    record.process_start = None;
227    record.final_output = extract_final_output_files(&stdout_path, &stderr_path);
228    replace_record(store, record.clone())?;
229    Ok(record)
230}
231
232fn fail_started_run(
233    store: &RoutineStore,
234    mut child: std::process::Child,
235    mut record: RunRecord,
236    message: &str,
237    out_thread: Option<thread::JoinHandle<Result<(), RoutineError>>>,
238    err_thread: Option<thread::JoinHandle<Result<(), RoutineError>>>,
239) -> Result<RunRecord, RoutineError> {
240    let pid = child.id() as i32;
241    unsafe {
242        libc::kill(-pid, libc::SIGTERM);
243    }
244    let mut status = None;
245    for _ in 0..20 {
246        if status.is_none() {
247            match child.try_wait() {
248                Ok(Some(value)) => status = Some(value),
249                Ok(None) => {}
250                Err(_) => break,
251            }
252        }
253        if !process_group_exists(pid) {
254            break;
255        }
256        thread::sleep(std::time::Duration::from_millis(100));
257    }
258    if status.is_none() || process_group_exists(pid) {
259        unsafe {
260            libc::kill(-pid, libc::SIGKILL);
261        }
262        if status.is_none() {
263            status = child.wait().ok();
264        }
265    }
266    if let Some(handle) = out_thread {
267        let _ = handle.join();
268    }
269    if let Some(handle) = err_thread {
270        let _ = handle.join();
271    }
272    record.finished_epoch = Some(now_epoch());
273    record.status = RunStatus::Failed;
274    record.exit_code = status.and_then(|status| status.code());
275    record.pid = None;
276    record.process_start = None;
277    record.final_output = message.to_string();
278    replace_record(store, record)?;
279    Err(RoutineError::Io(message.to_string()))
280}
281
282fn process_group_exists(pgid: i32) -> bool {
283    if unsafe { libc::kill(-pgid, 0) } == 0 {
284        return true;
285    }
286    std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
287}
288
289fn terminate_remaining_process_group(pgid: i32) {
290    if !process_group_exists(pgid) {
291        return;
292    }
293    unsafe {
294        libc::kill(-pgid, libc::SIGTERM);
295    }
296    for _ in 0..20 {
297        if !process_group_exists(pgid) {
298            return;
299        }
300        thread::sleep(std::time::Duration::from_millis(100));
301    }
302    // ^ Repeat KILL while polling to close pipes inherited by descendants that
303    // forked during TERM handling before reader threads are joined.
304    for _ in 0..20 {
305        unsafe {
306            libc::kill(-pgid, libc::SIGKILL);
307        }
308        if !process_group_exists(pgid) {
309            return;
310        }
311        thread::sleep(std::time::Duration::from_millis(100));
312    }
313}
314
315pub(crate) fn process_start(pid: i32) -> Option<String> {
316    #[cfg(target_os = "macos")]
317    {
318        let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::zeroed();
319        let size = std::mem::size_of::<libc::proc_bsdinfo>() as i32;
320        let read = unsafe {
321            libc::proc_pidinfo(
322                pid,
323                libc::PROC_PIDTBSDINFO,
324                0,
325                info.as_mut_ptr().cast(),
326                size,
327            )
328        };
329        if read != size {
330            return None;
331        }
332        let info = unsafe { info.assume_init() };
333        Some(format!(
334            "{}:{}",
335            info.pbi_start_tvsec, info.pbi_start_tvusec
336        ))
337    }
338    #[cfg(not(target_os = "macos"))]
339    {
340        let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
341        let after_name = stat.rsplit_once(") ")?.1;
342        after_name.split_whitespace().nth(19).map(str::to_string)
343    }
344}
345
346fn drain(
347    mut source: impl Read + Send + 'static,
348    path: std::path::PathBuf,
349) -> thread::JoinHandle<Result<(), RoutineError>> {
350    thread::spawn(move || {
351        let mut file = File::create(path)?;
352        file.set_permissions(fs::Permissions::from_mode(0o600))?;
353        copy_bounded_log(&mut source, &mut file)?;
354        file.sync_all()?;
355        Ok(())
356    })
357}
358
359fn copy_bounded_log(mut source: impl Read, mut target: impl Write) -> Result<(), RoutineError> {
360    let prefix_limit = MAX_RUN_LOG_BYTES - RUN_LOG_TAIL_BYTES - TRUNCATION_MARKER.len();
361    let mut prefix_written = 0;
362    let mut tail = Vec::with_capacity(RUN_LOG_TAIL_BYTES);
363    let mut truncated = false;
364    let mut buffer = [0_u8; 16 * 1024];
365    loop {
366        let read = source.read(&mut buffer)?;
367        if read == 0 {
368            break;
369        }
370        let mut chunk = &buffer[..read];
371        if prefix_written < prefix_limit {
372            let keep = chunk.len().min(prefix_limit - prefix_written);
373            target.write_all(&chunk[..keep])?;
374            prefix_written += keep;
375            chunk = &chunk[keep..];
376        }
377        if chunk.is_empty() {
378            continue;
379        }
380        if tail.len() + chunk.len() > RUN_LOG_TAIL_BYTES {
381            truncated = true;
382            let discard = (tail.len() + chunk.len()) - RUN_LOG_TAIL_BYTES;
383            if discard >= tail.len() {
384                tail.clear();
385                let start = chunk.len().saturating_sub(RUN_LOG_TAIL_BYTES);
386                tail.extend_from_slice(&chunk[start..]);
387            } else {
388                tail.drain(..discard);
389                tail.extend_from_slice(chunk);
390            }
391        } else {
392            tail.extend_from_slice(chunk);
393        }
394    }
395    if truncated {
396        target.write_all(TRUNCATION_MARKER)?;
397    }
398    target.write_all(&tail)?;
399    Ok(())
400}
401
402fn append_record(store: &RoutineStore, record: RunRecord) -> Result<(), RoutineError> {
403    store.modify_runtime(|state| {
404        state
405            .runs
406            .entry(record.routine.clone())
407            .or_default()
408            .push(record);
409        Ok(())
410    })
411}
412
413fn replace_record(store: &RoutineStore, record: RunRecord) -> Result<(), RoutineError> {
414    let routine = record.routine.clone();
415    let removed = store.modify_runtime(|state| {
416        let runs = state.runs.entry(record.routine.clone()).or_default();
417        if let Some(existing) = runs.iter_mut().find(|run| run.id == record.id) {
418            *existing = record;
419        }
420        Ok(if runs.len() > MAX_RUNS {
421            runs.drain(0..runs.len() - MAX_RUNS).collect::<Vec<_>>()
422        } else {
423            vec![]
424        })
425    })?;
426    for old in removed {
427        prune_run_logs(store, &routine, &old);
428    }
429    Ok(())
430}
431
432pub(crate) fn prune_run_logs(store: &RoutineStore, routine: &str, run: &RunRecord) {
433    // ^ Runtime state is persisted input. Only remove a single validated child
434    // derived from the trusted routine log root, never a stored output path.
435    if run.routine != routine || !is_safe_run_id(&run.id) {
436        return;
437    }
438    let dir = store.logs_dir().join(hex_name(routine)).join(&run.id);
439    let _ = fs::remove_dir_all(dir);
440}
441
442fn is_safe_run_id(id: &str) -> bool {
443    let Some((seconds, nanos)) = id.split_once('-') else {
444        return false;
445    };
446    !seconds.is_empty()
447        && !nanos.is_empty()
448        && seconds.bytes().all(|byte| byte.is_ascii_digit())
449        && nanos.bytes().all(|byte| byte.is_ascii_digit())
450}
451
452pub fn extract_final_output(stdout: &str, stderr: &str) -> String {
453    extract_final_output_bytes(stdout.as_bytes(), stderr.as_bytes())
454}
455
456fn extract_final_output_bytes(stdout: &[u8], stderr: &[u8]) -> String {
457    let stdout = String::from_utf8_lossy(stdout);
458    let stderr = String::from_utf8_lossy(stderr);
459    let mut codex = None;
460    let mut claude = None;
461    for line in stdout
462        .lines()
463        .filter(|line| line.len() <= STRUCTURED_LINE_BYTES)
464    {
465        let Ok(value) = serde_json::from_str::<Value>(line) else {
466            continue;
467        };
468        if value.get("type").and_then(Value::as_str) == Some("item.completed") {
469            let item = &value["item"];
470            if item.get("type").and_then(Value::as_str) == Some("agent_message") {
471                codex = item
472                    .get("text")
473                    .and_then(Value::as_str)
474                    .map(|text| text_tail(text, FINAL_OUTPUT_BYTES));
475            }
476        }
477        if value.get("type").and_then(Value::as_str) == Some("result") {
478            claude = value
479                .get("result")
480                .and_then(Value::as_str)
481                .map(|text| text_tail(text, FINAL_OUTPUT_BYTES));
482        } else if value.get("type").and_then(Value::as_str) == Some("assistant") {
483            if let Some(text) = claude_assistant_text(&value) {
484                claude = Some(text_tail(&text, FINAL_OUTPUT_BYTES));
485            }
486        }
487    }
488    codex
489        .or(claude)
490        .filter(|s| !s.trim().is_empty())
491        .unwrap_or_else(|| {
492            if !stdout.trim().is_empty() {
493                text_tail(stdout.trim(), FINAL_OUTPUT_BYTES)
494            } else {
495                text_tail(stderr.trim(), FINAL_OUTPUT_BYTES)
496            }
497        })
498}
499
500fn extract_final_output_files(
501    stdout_path: &std::path::Path,
502    stderr_path: &std::path::Path,
503) -> String {
504    let (codex, claude) = File::open(stdout_path)
505        .ok()
506        .map(scan_structured_output)
507        .unwrap_or_default();
508    codex
509        .or(claude)
510        .filter(|output| !output.trim().is_empty())
511        .unwrap_or_else(|| {
512            let stdout = bounded_file_tail(stdout_path);
513            if stdout.is_empty() {
514                bounded_file_tail(stderr_path)
515            } else {
516                stdout
517            }
518        })
519}
520
521fn scan_structured_output(file: File) -> (Option<String>, Option<String>) {
522    let mut codex = None;
523    let mut claude = None;
524    let mut reader = BufReader::new(file);
525    loop {
526        let Some(line) = read_bounded_line(&mut reader, STRUCTURED_LINE_BYTES) else {
527            break;
528        };
529        let Some(line) = line else {
530            continue;
531        };
532        let Ok(value) = serde_json::from_slice::<Value>(&line) else {
533            continue;
534        };
535        if value.get("type").and_then(Value::as_str) == Some("item.completed") {
536            let item = &value["item"];
537            if item.get("type").and_then(Value::as_str) == Some("agent_message") {
538                codex = item
539                    .get("text")
540                    .and_then(Value::as_str)
541                    .map(|text| text_tail(text, FINAL_OUTPUT_BYTES));
542            }
543        }
544        if value.get("type").and_then(Value::as_str) == Some("result") {
545            claude = value
546                .get("result")
547                .and_then(Value::as_str)
548                .map(|text| text_tail(text, FINAL_OUTPUT_BYTES));
549        } else if value.get("type").and_then(Value::as_str) == Some("assistant") {
550            if let Some(text) = claude_assistant_text(&value) {
551                claude = Some(text_tail(&text, FINAL_OUTPUT_BYTES));
552            }
553        }
554    }
555    (codex, claude)
556}
557
558fn read_bounded_line(reader: &mut impl BufRead, max_bytes: usize) -> Option<Option<Vec<u8>>> {
559    let mut line = Vec::new();
560    let mut oversized = false;
561    loop {
562        let buffer = reader.fill_buf().ok()?;
563        if buffer.is_empty() {
564            return (!line.is_empty() || oversized).then_some((!oversized).then_some(line));
565        }
566        let newline = buffer.iter().position(|byte| *byte == b'\n');
567        let consumed = newline.map_or(buffer.len(), |index| index + 1);
568        let content_len = newline.unwrap_or(buffer.len());
569        if !oversized && line.len().saturating_add(content_len) <= max_bytes {
570            line.extend_from_slice(&buffer[..content_len]);
571        } else {
572            oversized = true;
573            line.clear();
574        }
575        reader.consume(consumed);
576        if newline.is_some() {
577            return Some((!oversized).then_some(line));
578        }
579    }
580}
581
582fn bounded_file_tail(path: &std::path::Path) -> String {
583    const READ_CHUNK_BYTES: usize = 4 * 1024;
584
585    let Ok(mut file) = File::open(path) else {
586        return String::new();
587    };
588    let Ok(mut position) = file.seek(SeekFrom::End(0)) else {
589        return String::new();
590    };
591    let mut tail = Vec::with_capacity(FINAL_OUTPUT_BYTES);
592    let mut found_content = false;
593    while position > 0 && tail.len() < FINAL_OUTPUT_BYTES {
594        let chunk_len = usize::try_from(position.min(READ_CHUNK_BYTES as u64)).unwrap_or(0);
595        position -= chunk_len as u64;
596        if file.seek(SeekFrom::Start(position)).is_err() {
597            break;
598        }
599        let mut chunk = vec![0; chunk_len];
600        if file.read_exact(&mut chunk).is_err() {
601            break;
602        }
603        if !found_content {
604            while chunk.last().is_some_and(u8::is_ascii_whitespace) {
605                chunk.pop();
606            }
607            found_content = !chunk.is_empty();
608        }
609        if found_content {
610            let remaining = FINAL_OUTPUT_BYTES - tail.len();
611            let start = chunk.len().saturating_sub(remaining);
612            chunk.drain(..start);
613            chunk.extend_from_slice(&tail);
614            tail = chunk;
615        }
616    }
617    String::from_utf8_lossy(&tail).trim().to_string()
618}
619
620fn claude_assistant_text(value: &Value) -> Option<String> {
621    let content = value.get("message")?.get("content")?.as_array()?;
622    let text = content
623        .iter()
624        .filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
625        .filter_map(|block| block.get("text").and_then(Value::as_str))
626        .collect::<Vec<_>>()
627        .join("\n");
628    (!text.trim().is_empty()).then_some(text)
629}
630
631fn text_tail(text: &str, max_bytes: usize) -> String {
632    if text.len() <= max_bytes {
633        return text.to_string();
634    }
635    let mut start = text.len() - max_bytes;
636    while !text.is_char_boundary(start) {
637        start += 1;
638    }
639    text[start..].to_string()
640}
641
642fn hex_name(name: &str) -> String {
643    name.as_bytes().iter().map(|b| format!("{b:02x}")).collect()
644}
645fn now_epoch() -> i64 {
646    SystemTime::now()
647        .duration_since(UNIX_EPOCH)
648        .unwrap_or_default()
649        .as_secs() as i64
650}
651fn now_nanos() -> u128 {
652    SystemTime::now()
653        .duration_since(UNIX_EPOCH)
654        .unwrap_or_default()
655        .as_nanos()
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use crate::routine::Trigger;
662    use std::cell::Cell;
663    use std::io::Cursor;
664    use std::path::PathBuf;
665    use std::rc::Rc;
666    use std::sync::atomic::{AtomicI32, AtomicU64, Ordering};
667
668    static NEXT: AtomicU64 = AtomicU64::new(0);
669
670    struct CountingReader {
671        source: Cursor<Vec<u8>>,
672        consumed: Rc<Cell<usize>>,
673    }
674
675    impl Read for CountingReader {
676        fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
677            let read = self.source.read(buffer)?;
678            self.consumed.set(self.consumed.get() + read);
679            Ok(read)
680        }
681    }
682
683    #[test]
684    fn given_log_within_cap_when_copied_then_every_byte_is_preserved_without_marker() {
685        let input = b"begin\ncomplete tail".to_vec();
686        let consumed = Rc::new(Cell::new(0));
687        let reader = CountingReader {
688            source: Cursor::new(input.clone()),
689            consumed: Rc::clone(&consumed),
690        };
691        let mut output = Vec::new();
692
693        copy_bounded_log(reader, &mut output).unwrap();
694
695        assert_eq!(
696            (
697                output == input,
698                output
699                    .windows(TRUNCATION_MARKER.len())
700                    .any(|window| window == TRUNCATION_MARKER),
701                consumed.get(),
702            ),
703            (true, false, input.len())
704        );
705    }
706
707    #[test]
708    fn given_log_over_cap_when_copied_then_prefix_and_tail_are_bounded_and_input_is_consumed() {
709        let mut input = b"BEGIN".to_vec();
710        input.resize(MAX_RUN_LOG_BYTES + 4_096, b'x');
711        input.extend_from_slice(b"FINAL-TAIL");
712        let consumed = Rc::new(Cell::new(0));
713        let reader = CountingReader {
714            source: Cursor::new(input.clone()),
715            consumed: Rc::clone(&consumed),
716        };
717        let mut output = Vec::new();
718
719        copy_bounded_log(reader, &mut output).unwrap();
720        let markers = output
721            .windows(TRUNCATION_MARKER.len())
722            .filter(|window| *window == TRUNCATION_MARKER)
723            .count();
724
725        assert_eq!(
726            (
727                output.len() <= MAX_RUN_LOG_BYTES,
728                output.starts_with(b"BEGIN"),
729                output.ends_with(b"FINAL-TAIL"),
730                markers,
731                consumed.get(),
732            ),
733            (true, true, true, 1, input.len())
734        );
735    }
736
737    #[test]
738    fn exact_prompt_is_replaced_otherwise_stdin_is_used() {
739        let replaced = Routine {
740            name: "x".into(),
741            trigger: Trigger::Cron("* * * * *".into()),
742            command: vec!["echo".into(), "{prompt}".into()],
743            prompt: "hi".into(),
744            enabled: true,
745        };
746        assert_eq!(expanded_argv(&replaced).0, vec!["echo", "hi"]);
747        assert!(expanded_argv(&replaced).1.is_none());
748        let stdin = Routine {
749            command: vec!["cat".into()],
750            ..replaced
751        };
752        assert_eq!(expanded_argv(&stdin).1, Some("hi".as_bytes()));
753    }
754
755    #[test]
756    fn extracts_codex_claude_and_fallback_output() {
757        assert_eq!(extract_final_output("{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"done\"}}\n", ""), "done");
758        assert_eq!(
759            extract_final_output("{\"type\":\"result\",\"result\":\"answer\"}\n", ""),
760            "answer"
761        );
762        assert_eq!(extract_final_output("plain\n", "err"), "plain");
763        assert_eq!(extract_final_output("", "err\n"), "err");
764    }
765
766    #[test]
767    fn plain_fallback_is_bounded_to_the_log_tail() {
768        let output = format!("HEAD\n{}TAIL", "progress\n".repeat(4_000));
769        let extracted = extract_final_output(&output, "");
770        assert!(extracted.len() <= 16 * 1024);
771        assert!(extracted.ends_with("TAIL"));
772        assert!(!extracted.contains("HEAD"));
773    }
774
775    #[test]
776    fn claude_extraction_ignores_nested_tool_payload_text() {
777        let output = concat!(
778            "{\"type\":\"assistant\",\"message\":{\"content\":[",
779            "{\"type\":\"text\",\"text\":\"final answer\"},",
780            "{\"type\":\"tool_use\",\"input\":{\"text\":\"tool payload\"}}]}}\n"
781        );
782        assert_eq!(extract_final_output(output, ""), "final answer");
783    }
784
785    #[test]
786    fn tool_only_structured_output_uses_bounded_plain_fallback() {
787        let output = concat!(
788            "{\"type\":\"assistant\",\"message\":{\"content\":[",
789            "{\"type\":\"tool_use\",\"input\":{\"text\":\"not an answer\"}}]}}\n"
790        );
791        assert_eq!(extract_final_output(output, ""), output.trim());
792    }
793
794    #[test]
795    fn invalid_utf8_is_preserved_in_plain_fallback() {
796        assert_eq!(
797            extract_final_output_bytes(b"before\xffafter\n", b""),
798            "before\u{fffd}after"
799        );
800    }
801
802    #[test]
803    fn file_extraction_streams_structured_output_and_bounds_plain_tail() {
804        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
805            .join("../../target/routine-execution-tests")
806            .join(format!(
807                "{}-{}",
808                std::process::id(),
809                NEXT.fetch_add(1, Ordering::Relaxed)
810            ));
811        fs::create_dir_all(&root).unwrap();
812        let stdout_path = root.join("stdout.log");
813        let stderr_path = root.join("stderr.log");
814        let mut stdout = File::create(&stdout_path).unwrap();
815        for _ in 0..10_000 {
816            writeln!(stdout, "progress output that must not be retained").unwrap();
817        }
818        writeln!(
819            stdout,
820            "{{\"type\":\"result\",\"result\":\"final answer\"}}"
821        )
822        .unwrap();
823        fs::write(&stderr_path, "error").unwrap();
824
825        assert_eq!(
826            extract_final_output_files(&stdout_path, &stderr_path),
827            "final answer"
828        );
829
830        fs::write(
831            &stdout_path,
832            format!("HEAD\n{}TAIL\n", "progress\n".repeat(4_000)),
833        )
834        .unwrap();
835        let fallback = extract_final_output_files(&stdout_path, &stderr_path);
836        assert!(fallback.len() <= 16 * 1024);
837        assert!(fallback.ends_with("TAIL"));
838        assert!(!fallback.contains("HEAD"));
839        let _ = fs::remove_dir_all(root);
840    }
841
842    #[test]
843    fn oversized_structured_line_is_discarded_without_hiding_later_result() {
844        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
845            .join("../../target/routine-execution-tests")
846            .join(format!(
847                "{}-{}",
848                std::process::id(),
849                NEXT.fetch_add(1, Ordering::Relaxed)
850            ));
851        fs::create_dir_all(&root).unwrap();
852        let stdout_path = root.join("stdout.log");
853        let stderr_path = root.join("stderr.log");
854        let mut stdout = File::create(&stdout_path).unwrap();
855        writeln!(
856            stdout,
857            "{{\"type\":\"result\",\"result\":\"{}\"}}",
858            "x".repeat(STRUCTURED_LINE_BYTES)
859        )
860        .unwrap();
861        writeln!(stdout, "{{\"type\":\"result\",\"result\":\"bounded\"}}").unwrap();
862        fs::write(&stderr_path, "").unwrap();
863
864        assert_eq!(
865            extract_final_output_files(&stdout_path, &stderr_path),
866            "bounded"
867        );
868        let _ = fs::remove_dir_all(root);
869    }
870
871    #[test]
872    fn empty_public_command_returns_validation_instead_of_panicking() {
873        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
874            .join("../../target/routine-execution-tests")
875            .join(format!(
876                "{}-{}",
877                std::process::id(),
878                NEXT.fetch_add(1, Ordering::Relaxed)
879            ));
880        let project =
881            fs::canonicalize(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")).unwrap();
882        let store = RoutineStore::new(root.clone(), &project).unwrap();
883        let routine = Routine {
884            name: "empty".into(),
885            trigger: Trigger::Cron("* * * * *".into()),
886            command: Vec::new(),
887            prompt: String::new(),
888            enabled: true,
889        };
890
891        assert!(matches!(
892            execute(&store, &routine, None),
893            Err(RoutineError::Validation(_))
894        ));
895        assert!(!store.load_runtime().unwrap().runs.contains_key("empty"));
896        let _ = fs::remove_dir_all(root);
897    }
898
899    #[test]
900    fn successful_leader_exit_does_not_leave_pipe_holding_descendants() {
901        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
902            .join("../../target/routine-execution-tests")
903            .join(format!(
904                "{}-{}",
905                std::process::id(),
906                NEXT.fetch_add(1, Ordering::Relaxed)
907            ));
908        let project =
909            fs::canonicalize(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")).unwrap();
910        let store = RoutineStore::new(root.clone(), &project).unwrap();
911        let routine = Routine {
912            name: "descendant".into(),
913            trigger: Trigger::Cron("* * * * *".into()),
914            command: vec![
915                "/bin/sh".into(),
916                "-c".into(),
917                "(trap '' TERM; sleep 30) & exit 0".into(),
918            ],
919            prompt: String::new(),
920            enabled: true,
921        };
922        let pid = AtomicI32::new(0);
923
924        let record = execute_supervised(&store, &routine, None, |record| {
925            pid.store(record.pid.unwrap(), Ordering::SeqCst);
926        })
927        .unwrap();
928
929        assert_eq!(record.status, RunStatus::Succeeded);
930        assert_eq!(unsafe { libc::kill(-pid.load(Ordering::SeqCst), 0) }, -1);
931        let _ = fs::remove_dir_all(root);
932    }
933
934    #[test]
935    fn retention_never_deletes_a_directory_from_persisted_output_paths() {
936        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
937            .join("../../target/routine-execution-tests")
938            .join(format!(
939                "{}-{}",
940                std::process::id(),
941                NEXT.fetch_add(1, Ordering::Relaxed)
942            ));
943        let project =
944            fs::canonicalize(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")).unwrap();
945        let store = RoutineStore::new(root.clone(), &project).unwrap();
946        let victim = root.join("must-survive");
947        fs::create_dir_all(&victim).unwrap();
948        fs::write(victim.join("marker"), "present").unwrap();
949        let record = RunRecord {
950            id: "../must-survive".into(),
951            routine: "safe".into(),
952            started_epoch: 1,
953            finished_epoch: Some(2),
954            scheduled_epoch_minute: None,
955            cause: RunCause::Manual,
956            status: RunStatus::Succeeded,
957            exit_code: Some(0),
958            pid: None,
959            process_start: None,
960            final_output: String::new(),
961            stdout_path: victim.join("stdout.log"),
962            stderr_path: victim.join("stderr.log"),
963        };
964
965        prune_run_logs(&store, "safe", &record);
966
967        assert!(victim.join("marker").exists());
968        let _ = fs::remove_dir_all(root);
969    }
970
971    #[test]
972    fn prompt_write_failure_terminates_and_reaps_process_group() {
973        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
974            .join("../../target/routine-execution-tests")
975            .join(format!(
976                "{}-{}",
977                std::process::id(),
978                NEXT.fetch_add(1, Ordering::Relaxed)
979            ));
980        let project =
981            fs::canonicalize(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")).unwrap();
982        let store = RoutineStore::new(root.clone(), &project).unwrap();
983        let routine = Routine {
984            name: "broken-stdin".into(),
985            trigger: Trigger::Cron("* * * * *".into()),
986            command: vec![
987                "/bin/sh".into(),
988                "-c".into(),
989                "exec 0<&-; trap '' TERM; sleep 30".into(),
990            ],
991            prompt: "x".repeat(1024 * 1024),
992            enabled: true,
993        };
994        let pid = AtomicI32::new(0);
995        let result = execute_supervised(&store, &routine, None, |record| {
996            pid.store(record.pid.unwrap(), Ordering::SeqCst);
997        });
998        assert!(matches!(result, Err(RoutineError::Io(_))));
999        let pid = pid.load(Ordering::SeqCst);
1000        assert!(pid > 0);
1001        assert_eq!(unsafe { libc::kill(-pid, 0) }, -1);
1002        let record = store.load_runtime().unwrap().runs["broken-stdin"]
1003            .last()
1004            .unwrap()
1005            .clone();
1006        assert_eq!(record.status, RunStatus::Failed);
1007        assert!(record.pid.is_none());
1008        let _ = fs::remove_dir_all(root);
1009    }
1010}