Skip to main content

harness_loop/daemon/
mod.rs

1//! Optional standalone scheduler for the harness-rs ecosystem.
2//!
3//! This crate is **not** depended on by any other harness-rs-* crate; you install
4//! it only if you want background scheduled jobs. It's a thin wrapper around
5//! `tokio` time + a TOML config that lets you say "run *this shell command* on
6//! *this schedule*" — typically pointing at a harness-rs-built agent binary
7//! like `assistant --brief`.
8//!
9//! Design split:
10//! - **Agent binary** = single-shot or REPL, runs once per invocation. No
11//!   daemon in its address space.
12//! - **`harness-daemon`** = long-lived process that wakes up on schedule and
13//!   spawns the agent binary as a subprocess. Optional. Crashes / restarts
14//!   independently of the agent.
15//!
16//! Use the included `harness-daemon` binary, or build your own daemon by
17//! constructing a [`Daemon`] in code.
18
19#[allow(unused_imports)]
20use chrono::Timelike;
21use chrono::{DateTime, Datelike, Duration, Local, NaiveTime, TimeZone, Weekday};
22use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24use std::path::PathBuf;
25use thiserror::Error;
26use tokio::process::Command;
27
28// =================================================================
29// Config types
30// =================================================================
31
32/// Top-level config — a list of `[[job]]` entries in TOML.
33#[derive(Debug, Clone, Serialize, Deserialize, Default)]
34pub struct DaemonConfig {
35    #[serde(default, rename = "job")]
36    pub jobs: Vec<Job>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Job {
41    /// Human-readable name; appears in logs.
42    pub name: String,
43    /// When to run. See [`Schedule`].
44    pub schedule: String,
45    /// Shell command to execute. Parsed with whitespace split (no quoting).
46    /// For arguments with spaces use `argv` instead.
47    #[serde(default)]
48    pub command: Option<String>,
49    /// Explicit argv: `["assistant", "--brief", "--tier", "flash"]`. Wins over `command`.
50    #[serde(default)]
51    pub argv: Option<Vec<String>>,
52    /// Per-job environment variables. Layered on top of the daemon's own env.
53    #[serde(default)]
54    pub env: HashMap<String, String>,
55    /// Working directory for the spawned process. Defaults to $HOME.
56    #[serde(default)]
57    pub cwd: Option<PathBuf>,
58    /// Job is disabled if false. Default true.
59    #[serde(default = "default_enabled")]
60    pub enabled: bool,
61}
62fn default_enabled() -> bool {
63    true
64}
65
66// =================================================================
67// Schedule parser
68// =================================================================
69
70/// Parsed schedule expression. Three forms today:
71///
72/// | TOML string | Meaning |
73/// |-------------|---------|
74/// | `"daily 08:00"`             | once per day at 08:00 local |
75/// | `"weekly mon 09:30"`        | every Monday at 09:30 local |
76/// | `"every 5m"` / `"every 1h"` | fixed interval, starts now |
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum Schedule {
79    Daily {
80        hour: u8,
81        minute: u8,
82    },
83    Weekly {
84        weekday: Weekday,
85        hour: u8,
86        minute: u8,
87    },
88    Interval(Duration),
89}
90
91#[derive(Debug, Error)]
92pub enum ScheduleError {
93    #[error(
94        "schedule string must be 'daily HH:MM', 'weekly <day> HH:MM', or 'every Nm/h/d/s'; got: {0}"
95    )]
96    Format(String),
97    #[error("invalid time `{0}` — use HH:MM")]
98    BadTime(String),
99    #[error("invalid weekday `{0}` — use mon/tue/wed/thu/fri/sat/sun")]
100    BadWeekday(String),
101    #[error("invalid interval `{0}` — use Ns / Nm / Nh / Nd")]
102    BadInterval(String),
103}
104
105impl Schedule {
106    pub fn parse(s: &str) -> Result<Self, ScheduleError> {
107        let s = s.trim();
108        if let Some(rest) = s.strip_prefix("daily ") {
109            let (h, m) = parse_hhmm(rest)?;
110            return Ok(Schedule::Daily { hour: h, minute: m });
111        }
112        if let Some(rest) = s.strip_prefix("weekly ") {
113            let mut parts = rest.split_whitespace();
114            let wd_str = parts
115                .next()
116                .ok_or_else(|| ScheduleError::Format(s.to_string()))?;
117            let time_str = parts
118                .next()
119                .ok_or_else(|| ScheduleError::Format(s.to_string()))?;
120            let weekday = parse_weekday(wd_str)?;
121            let (h, m) = parse_hhmm(time_str)?;
122            return Ok(Schedule::Weekly {
123                weekday,
124                hour: h,
125                minute: m,
126            });
127        }
128        if let Some(rest) = s.strip_prefix("every ") {
129            return parse_interval(rest).map(Schedule::Interval);
130        }
131        Err(ScheduleError::Format(s.to_string()))
132    }
133
134    /// Compute the next fire time strictly after `now`.
135    pub fn next_after(&self, now: DateTime<Local>) -> DateTime<Local> {
136        match *self {
137            Schedule::Daily { hour, minute } => {
138                let today = now.date_naive();
139                let t = NaiveTime::from_hms_opt(hour.into(), minute.into(), 0).unwrap();
140                let candidate = Local
141                    .from_local_datetime(&today.and_time(t))
142                    .earliest()
143                    .unwrap();
144                if candidate > now {
145                    candidate
146                } else {
147                    candidate + Duration::days(1)
148                }
149            }
150            Schedule::Weekly {
151                weekday,
152                hour,
153                minute,
154            } => {
155                // Step forward up to 7 days to find the right weekday.
156                let mut candidate = now;
157                for _ in 0..8 {
158                    if candidate.weekday() == weekday {
159                        let t = NaiveTime::from_hms_opt(hour.into(), minute.into(), 0).unwrap();
160                        let attempt = Local
161                            .from_local_datetime(&candidate.date_naive().and_time(t))
162                            .earliest()
163                            .unwrap();
164                        if attempt > now {
165                            return attempt;
166                        }
167                    }
168                    candidate += Duration::days(1);
169                }
170                unreachable!("weekday must occur within 7 days")
171            }
172            Schedule::Interval(d) => now + d,
173        }
174    }
175}
176
177fn parse_hhmm(s: &str) -> Result<(u8, u8), ScheduleError> {
178    let (h, m) = s
179        .split_once(':')
180        .ok_or_else(|| ScheduleError::BadTime(s.into()))?;
181    let h: u8 = h.parse().map_err(|_| ScheduleError::BadTime(s.into()))?;
182    let m: u8 = m.parse().map_err(|_| ScheduleError::BadTime(s.into()))?;
183    if h > 23 || m > 59 {
184        return Err(ScheduleError::BadTime(s.into()));
185    }
186    Ok((h, m))
187}
188
189fn parse_weekday(s: &str) -> Result<Weekday, ScheduleError> {
190    match s.to_lowercase().as_str() {
191        "mon" | "monday" => Ok(Weekday::Mon),
192        "tue" | "tuesday" => Ok(Weekday::Tue),
193        "wed" | "wednesday" => Ok(Weekday::Wed),
194        "thu" | "thursday" => Ok(Weekday::Thu),
195        "fri" | "friday" => Ok(Weekday::Fri),
196        "sat" | "saturday" => Ok(Weekday::Sat),
197        "sun" | "sunday" => Ok(Weekday::Sun),
198        _ => Err(ScheduleError::BadWeekday(s.into())),
199    }
200}
201
202fn parse_interval(s: &str) -> Result<Duration, ScheduleError> {
203    let s = s.trim();
204    if s.len() < 2 {
205        return Err(ScheduleError::BadInterval(s.into()));
206    }
207    let (n_str, unit) = s.split_at(s.len() - 1);
208    let n: i64 = n_str
209        .trim()
210        .parse()
211        .map_err(|_| ScheduleError::BadInterval(s.into()))?;
212    match unit {
213        "s" => Ok(Duration::seconds(n)),
214        "m" => Ok(Duration::minutes(n)),
215        "h" => Ok(Duration::hours(n)),
216        "d" => Ok(Duration::days(n)),
217        _ => Err(ScheduleError::BadInterval(s.into())),
218    }
219}
220
221// =================================================================
222// Daemon runtime
223// =================================================================
224
225#[derive(Debug, Error)]
226pub enum DaemonError {
227    #[error("config: {0}")]
228    Config(String),
229    #[error("schedule: {0}")]
230    Schedule(#[from] ScheduleError),
231    #[error("job `{name}` has neither `command` nor `argv`")]
232    NoCommand { name: String },
233}
234
235pub struct Daemon {
236    pub jobs: Vec<ResolvedJob>,
237}
238
239#[derive(Debug, Clone)]
240pub struct ResolvedJob {
241    pub name: String,
242    pub schedule: Schedule,
243    pub argv: Vec<String>,
244    pub env: HashMap<String, String>,
245    pub cwd: Option<PathBuf>,
246}
247
248impl Daemon {
249    pub fn from_config(cfg: DaemonConfig) -> Result<Self, DaemonError> {
250        let mut resolved = Vec::new();
251        for j in cfg.jobs {
252            if !j.enabled {
253                continue;
254            }
255            let argv = match (j.argv, j.command) {
256                (Some(a), _) if !a.is_empty() => a,
257                (_, Some(c)) => c.split_whitespace().map(String::from).collect(),
258                _ => {
259                    return Err(DaemonError::NoCommand {
260                        name: j.name.clone(),
261                    });
262                }
263            };
264            resolved.push(ResolvedJob {
265                name: j.name,
266                schedule: Schedule::parse(&j.schedule)?,
267                argv,
268                env: j.env,
269                cwd: j.cwd,
270            });
271        }
272        Ok(Daemon { jobs: resolved })
273    }
274
275    /// Print a summary of next fire times for every job, then exit.
276    /// Useful for debugging a config without actually running anything.
277    pub fn dry_run(&self) {
278        let now = Local::now();
279        println!("now: {}", now.format("%Y-%m-%d %H:%M:%S %Z"));
280        if self.jobs.is_empty() {
281            println!("(no enabled jobs)");
282            return;
283        }
284        for j in &self.jobs {
285            let next = j.schedule.next_after(now);
286            let delta = next - now;
287            println!(
288                "  {:30}  next: {}  (in {})  cmd: {}",
289                j.name,
290                next.format("%Y-%m-%d %H:%M:%S"),
291                fmt_delta(delta),
292                j.argv.join(" "),
293            );
294        }
295    }
296
297    /// Run forever, firing each job at its scheduled time.
298    /// Each job runs in its own tokio task so a slow one doesn't block others.
299    pub async fn run(self) -> Result<(), DaemonError> {
300        if self.jobs.is_empty() {
301            tracing::warn!("no enabled jobs — daemon will idle until Ctrl-C");
302        }
303        let mut handles = Vec::new();
304        for job in self.jobs {
305            handles.push(tokio::spawn(run_job_loop(job)));
306        }
307        // Wait for Ctrl-C; if not running on a terminal that's OK — just await forever.
308        let _ = tokio::signal::ctrl_c().await;
309        tracing::info!("Ctrl-C received, shutting down");
310        for h in handles {
311            h.abort();
312        }
313        Ok(())
314    }
315}
316
317async fn run_job_loop(job: ResolvedJob) {
318    let started = Local::now();
319    tracing::info!(job = %job.name, "scheduled (next: {})",
320        job.schedule.next_after(started).format("%Y-%m-%d %H:%M:%S"));
321
322    loop {
323        let now = Local::now();
324        let next = job.schedule.next_after(now);
325        let wait = (next - now)
326            .to_std()
327            .unwrap_or(std::time::Duration::from_secs(1));
328        tokio::time::sleep(wait).await;
329
330        let started = std::time::Instant::now();
331        let result = run_once(&job).await;
332        let elapsed_ms = started.elapsed().as_millis();
333
334        match result {
335            Ok(status) if status.success() => {
336                tracing::info!(job = %job.name, ms = elapsed_ms, "✓ ok");
337            }
338            Ok(status) => {
339                tracing::warn!(job = %job.name, ms = elapsed_ms, code = ?status.code(), "✗ non-zero exit");
340            }
341            Err(e) => {
342                tracing::error!(job = %job.name, ms = elapsed_ms, error = %e, "✗ spawn failed");
343            }
344        }
345
346        // For interval schedules, "next" was relative to (already-elapsed) now.
347        // Don't busy-loop if job took longer than the interval.
348    }
349}
350
351async fn run_once(job: &ResolvedJob) -> std::io::Result<std::process::ExitStatus> {
352    let mut cmd = Command::new(&job.argv[0]);
353    if job.argv.len() > 1 {
354        cmd.args(&job.argv[1..]);
355    }
356    for (k, v) in &job.env {
357        cmd.env(k, v);
358    }
359    if let Some(cwd) = &job.cwd {
360        cmd.current_dir(cwd);
361    }
362    cmd.status().await
363}
364
365fn fmt_delta(d: Duration) -> String {
366    let total = d.num_seconds();
367    if total < 0 {
368        return "due".into();
369    }
370    let h = total / 3600;
371    let m = (total % 3600) / 60;
372    let s = total % 60;
373    if h > 0 {
374        format!("{h}h {m}m")
375    } else if m > 0 {
376        format!("{m}m {s}s")
377    } else {
378        format!("{s}s")
379    }
380}
381
382// =================================================================
383// Tests
384// =================================================================
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn parse_daily() {
392        assert_eq!(
393            Schedule::parse("daily 08:00").unwrap(),
394            Schedule::Daily { hour: 8, minute: 0 }
395        );
396        assert_eq!(
397            Schedule::parse("daily 23:59").unwrap(),
398            Schedule::Daily {
399                hour: 23,
400                minute: 59
401            }
402        );
403        assert!(Schedule::parse("daily 25:00").is_err());
404        assert!(Schedule::parse("daily noon").is_err());
405    }
406
407    #[test]
408    fn parse_weekly() {
409        let s = Schedule::parse("weekly mon 09:30").unwrap();
410        assert_eq!(
411            s,
412            Schedule::Weekly {
413                weekday: Weekday::Mon,
414                hour: 9,
415                minute: 30
416            }
417        );
418        assert!(Schedule::parse("weekly funday 09:30").is_err());
419    }
420
421    #[test]
422    fn parse_interval() {
423        assert_eq!(
424            Schedule::parse("every 30s").unwrap(),
425            Schedule::Interval(Duration::seconds(30))
426        );
427        assert_eq!(
428            Schedule::parse("every 5m").unwrap(),
429            Schedule::Interval(Duration::minutes(5))
430        );
431        assert_eq!(
432            Schedule::parse("every 2h").unwrap(),
433            Schedule::Interval(Duration::hours(2))
434        );
435        assert_eq!(
436            Schedule::parse("every 1d").unwrap(),
437            Schedule::Interval(Duration::days(1))
438        );
439        assert!(Schedule::parse("every 5min").is_err());
440    }
441
442    #[test]
443    fn daily_next_after() {
444        // 9am today, schedule is daily 8am → next is 8am tomorrow
445        let now = Local.with_ymd_and_hms(2026, 5, 16, 9, 0, 0).unwrap();
446        let next = Schedule::Daily { hour: 8, minute: 0 }.next_after(now);
447        assert_eq!(next.date_naive(), now.date_naive() + Duration::days(1));
448        assert_eq!(next.hour(), 8);
449        // 7am today, schedule is daily 8am → next is 8am today
450        let now = Local.with_ymd_and_hms(2026, 5, 16, 7, 0, 0).unwrap();
451        let next = Schedule::Daily { hour: 8, minute: 0 }.next_after(now);
452        assert_eq!(next.date_naive(), now.date_naive());
453        assert_eq!(next.hour(), 8);
454    }
455
456    #[test]
457    fn weekly_next_after() {
458        // Saturday 2026-05-16. Schedule: weekly mon 09:30 → next is Mon 2026-05-18 09:30
459        let sat = Local.with_ymd_and_hms(2026, 5, 16, 12, 0, 0).unwrap();
460        assert_eq!(sat.weekday(), Weekday::Sat);
461        let next = Schedule::Weekly {
462            weekday: Weekday::Mon,
463            hour: 9,
464            minute: 30,
465        }
466        .next_after(sat);
467        assert_eq!(next.weekday(), Weekday::Mon);
468        assert_eq!(next.hour(), 9);
469        assert_eq!(next.minute(), 30);
470    }
471
472    #[test]
473    fn config_roundtrip() {
474        let toml_src = r#"
475[[job]]
476name = "brief"
477schedule = "daily 08:00"
478argv = ["assistant", "--brief"]
479
480[[job]]
481name = "disabled-one"
482schedule = "every 5m"
483command = "echo hi"
484enabled = false
485"#;
486        let cfg: DaemonConfig = toml::from_str(toml_src).unwrap();
487        assert_eq!(cfg.jobs.len(), 2);
488        let daemon = Daemon::from_config(cfg).unwrap();
489        // disabled one dropped
490        assert_eq!(daemon.jobs.len(), 1);
491        assert_eq!(daemon.jobs[0].name, "brief");
492        assert_eq!(daemon.jobs[0].argv, vec!["assistant", "--brief"]);
493    }
494
495    // ====== audit #9: edge cases ======
496
497    #[test]
498    fn parse_zero_argv_falls_back_to_command_string() {
499        // `command = "echo hi there"` → split by whitespace
500        let toml_src = r#"
501[[job]]
502name = "echo"
503schedule = "every 1m"
504command = "echo hi there"
505"#;
506        let cfg: DaemonConfig = toml::from_str(toml_src).unwrap();
507        let d = Daemon::from_config(cfg).unwrap();
508        assert_eq!(d.jobs.len(), 1);
509        assert_eq!(d.jobs[0].argv, vec!["echo", "hi", "there"]);
510    }
511
512    #[test]
513    fn parse_argv_wins_over_command_when_both_present() {
514        let toml_src = r#"
515[[job]]
516name = "either-or"
517schedule = "every 1m"
518argv = ["cmd-a", "arg-1"]
519command = "cmd-b arg-2"
520"#;
521        let cfg: DaemonConfig = toml::from_str(toml_src).unwrap();
522        let d = Daemon::from_config(cfg).unwrap();
523        assert_eq!(d.jobs[0].argv, vec!["cmd-a", "arg-1"]);
524    }
525
526    #[test]
527    fn parse_empty_argv_then_command_is_used() {
528        let toml_src = r#"
529[[job]]
530name = "fallthrough"
531schedule = "every 1m"
532argv = []
533command = "echo from-command"
534"#;
535        let cfg: DaemonConfig = toml::from_str(toml_src).unwrap();
536        let d = Daemon::from_config(cfg).unwrap();
537        assert_eq!(d.jobs[0].argv, vec!["echo", "from-command"]);
538    }
539
540    #[test]
541    fn parse_job_with_neither_argv_nor_command_errors() {
542        let toml_src = r#"
543[[job]]
544name = "broken"
545schedule = "every 1m"
546"#;
547        let cfg: DaemonConfig = toml::from_str(toml_src).unwrap();
548        let r = Daemon::from_config(cfg);
549        match r {
550            Err(DaemonError::NoCommand { name }) => assert_eq!(name, "broken"),
551            Err(e) => panic!("expected NoCommand, got: {e}"),
552            Ok(_) => panic!("expected NoCommand, got Ok"),
553        }
554    }
555
556    #[test]
557    fn parse_bad_schedule_propagates() {
558        let toml_src = r#"
559[[job]]
560name = "x"
561schedule = "every 5min"
562command = "true"
563"#;
564        let cfg: DaemonConfig = toml::from_str(toml_src).unwrap();
565        let r = Daemon::from_config(cfg);
566        assert!(matches!(r, Err(DaemonError::Schedule(_))));
567    }
568
569    #[test]
570    fn weekly_wraps_to_next_week_when_today_is_after_target_time() {
571        // Today is Mon 12:00, schedule is "weekly mon 09:30" — target already
572        // passed today, so next fire is next Mon, NOT later today.
573        let mon = Local.with_ymd_and_hms(2026, 5, 18, 12, 0, 0).unwrap();
574        assert_eq!(mon.weekday(), Weekday::Mon);
575        let next = Schedule::Weekly {
576            weekday: Weekday::Mon,
577            hour: 9,
578            minute: 30,
579        }
580        .next_after(mon);
581        assert_eq!(next.weekday(), Weekday::Mon);
582        assert_eq!(next.date_naive(), mon.date_naive() + Duration::days(7));
583    }
584
585    #[test]
586    fn daily_midnight_boundary() {
587        // 23:30 today, schedule daily 00:05 → next is 00:05 tomorrow
588        let late = Local.with_ymd_and_hms(2026, 5, 16, 23, 30, 0).unwrap();
589        let next = Schedule::Daily { hour: 0, minute: 5 }.next_after(late);
590        assert_eq!(next.date_naive(), late.date_naive() + Duration::days(1));
591        assert_eq!(next.hour(), 0);
592        assert_eq!(next.minute(), 5);
593    }
594
595    #[test]
596    fn interval_next_is_always_now_plus_d() {
597        // Interval schedules don't anchor to a wall-clock time — they just
598        // measure from "now". This is a regression test against the temptation
599        // to anchor them.
600        let now = Local::now();
601        let s = Schedule::Interval(Duration::seconds(30));
602        let next = s.next_after(now);
603        let delta = next - now;
604        assert!(
605            delta >= Duration::seconds(29) && delta <= Duration::seconds(31),
606            "expected ~30s, got {delta:?}"
607        );
608    }
609
610    #[test]
611    fn fmt_delta_handles_hours_minutes_seconds() {
612        assert_eq!(fmt_delta(Duration::seconds(5)), "5s");
613        assert_eq!(fmt_delta(Duration::seconds(65)), "1m 5s");
614        assert_eq!(fmt_delta(Duration::seconds(3725)), "1h 2m");
615        assert_eq!(fmt_delta(Duration::seconds(-10)), "due");
616    }
617
618    #[test]
619    fn env_preserved_in_resolved_job() {
620        let toml_src = r#"
621[[job]]
622name = "with-env"
623schedule = "every 1m"
624command = "env"
625env = { FOO = "bar", BAZ = "qux" }
626"#;
627        let cfg: DaemonConfig = toml::from_str(toml_src).unwrap();
628        let d = Daemon::from_config(cfg).unwrap();
629        assert_eq!(d.jobs[0].env.get("FOO").map(String::as_str), Some("bar"));
630        assert_eq!(d.jobs[0].env.get("BAZ").map(String::as_str), Some("qux"));
631    }
632
633    #[test]
634    fn cwd_preserved_in_resolved_job() {
635        let toml_src = r#"
636[[job]]
637name = "with-cwd"
638schedule = "every 1m"
639command = "pwd"
640cwd = "/tmp"
641"#;
642        let cfg: DaemonConfig = toml::from_str(toml_src).unwrap();
643        let d = Daemon::from_config(cfg).unwrap();
644        assert_eq!(d.jobs[0].cwd.as_deref(), Some(std::path::Path::new("/tmp")));
645    }
646
647    #[test]
648    fn weekday_aliases_all_accepted() {
649        // Both short and long forms parse.
650        for s in ["mon", "monday", "Mon", "MONDAY"] {
651            assert!(matches!(
652                Schedule::parse(&format!("weekly {s} 09:00")).unwrap(),
653                Schedule::Weekly {
654                    weekday: Weekday::Mon,
655                    ..
656                }
657            ));
658        }
659        for s in ["sun", "sunday"] {
660            assert!(matches!(
661                Schedule::parse(&format!("weekly {s} 09:00")).unwrap(),
662                Schedule::Weekly {
663                    weekday: Weekday::Sun,
664                    ..
665                }
666            ));
667        }
668    }
669
670    /// Spawn an actual `true` subprocess via the daemon's run_once and confirm
671    /// the env + cwd are propagated. Uses a Bash-built-in (well, /bin/true)
672    /// so the test doesn't depend on any sysadmin binary beyond what every
673    /// Unix box has.
674    #[cfg(unix)]
675    #[tokio::test]
676    async fn run_once_executes_subprocess_with_env_and_cwd() {
677        let tmp = std::env::temp_dir().join(format!(
678            "harness-daemon-{}-{}",
679            std::process::id(),
680            std::time::SystemTime::now()
681                .duration_since(std::time::UNIX_EPOCH)
682                .unwrap()
683                .as_nanos()
684        ));
685        std::fs::create_dir_all(&tmp).unwrap();
686        // Write a marker via sh -c "echo $FOO > marker"; verify the env var
687        // and cwd land where we expect.
688        let job = ResolvedJob {
689            name: "test".into(),
690            schedule: Schedule::Interval(Duration::seconds(60)),
691            argv: vec!["sh".into(), "-c".into(), "echo \"$FOO\" > marker".into()],
692            env: std::collections::HashMap::from([("FOO".into(), "hello-edge".into())]),
693            cwd: Some(tmp.clone()),
694        };
695        let status = run_once(&job).await.expect("spawn");
696        assert!(status.success(), "subprocess exit: {status:?}");
697        let marker = std::fs::read_to_string(tmp.join("marker")).unwrap();
698        assert_eq!(marker.trim(), "hello-edge");
699        let _ = std::fs::remove_dir_all(&tmp);
700    }
701}