Skip to main content

sloop/runner/
local.rs

1use std::fs;
2use std::io::{self, Read, Write};
3use std::os::unix::fs::PermissionsExt;
4use std::os::unix::process::CommandExt;
5use std::path::{Path, PathBuf};
6use std::process::{Child, Command, Stdio};
7use std::sync::Arc;
8
9use base64::Engine;
10use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64_TOKEN;
11use tokio::net::UnixListener;
12
13use super::{
14    AgentProcessCheckpoint, ExecProcessCheckpoint, ExecutionEvidence, ExecutionFailure,
15    ProcessIdentity, RunnerError, StageExecution, StageHooks, StageOrder, WorkerCredentials,
16    WorkerScope,
17};
18use crate::clock::Clock;
19use crate::run_log::{OutputSource, OutputStream, RunLogWriter};
20
21/// An agent process under supervision: spawned, checkpointed, and being
22/// drained of output until it exits.
23pub struct LaunchedAgent {
24    child: Child,
25    readers: Vec<std::thread::JoinHandle<bool>>,
26    process: ProcessIdentity,
27    started_at_ms: i64,
28}
29
30pub struct AgentCompletion {
31    pub evidence: ExecutionEvidence,
32    pub wait_error: Option<String>,
33}
34
35impl LaunchedAgent {
36    pub fn process(&self) -> ProcessIdentity {
37        self.process
38    }
39
40    pub fn wait(mut self, clock: &dyn Clock) -> AgentCompletion {
41        let (exit_code, wait_error) = match self.child.wait() {
42            Ok(status) => (status.code(), None),
43            Err(error) => (None, Some(error.to_string())),
44        };
45        let stragglers_killed = kill_straggler_process_group(self.process.process_group_id);
46        let mut output_capture_complete = true;
47        for reader in self.readers {
48            output_capture_complete &= reader.join().unwrap_or(false);
49        }
50        AgentCompletion {
51            evidence: ExecutionEvidence {
52                exit_code,
53                started_at_ms: self.started_at_ms,
54                finished_at_ms: clock.now_ms(),
55                process: Some(self.process),
56                output_capture_complete,
57                stragglers_killed,
58            },
59            wait_error,
60        }
61    }
62}
63
64/// Creates the isolated workspace a run executes every one of its stages in.
65/// The driver does this once, before the first stage, because a flow need not
66/// open with an agent action.
67pub fn create_run_worktree(repository: &Path, worktree: &Path, branch: &str) -> Result<(), String> {
68    if worktree.exists() {
69        return Ok(());
70    }
71    fs::create_dir_all(
72        worktree
73            .parent()
74            .ok_or_else(|| "worktree has no parent".to_owned())?,
75    )
76    .map_err(|error| error.to_string())?;
77    let git = Command::new("git")
78        .args(["worktree", "add", "--quiet", "-b", branch])
79        .arg(worktree)
80        .current_dir(repository)
81        .output()
82        .map_err(|error| error.to_string())?;
83    if git.status.success() {
84        Ok(())
85    } else {
86        Err(format!(
87            "git worktree add failed: {}",
88            String::from_utf8_lossy(&git.stderr).trim()
89        ))
90    }
91}
92
93/// Binds a worker socket and mints the token that authenticates against it,
94/// scoped to what the token may do. Returned unregistered: the caller decides
95/// who serves the socket, and no request can be answered before it does.
96pub fn mint_worker_credentials(
97    socket_path: &Path,
98    scope: WorkerScope,
99) -> Result<(WorkerCredentials, UnixListener), String> {
100    let token = generate_worker_token()?;
101    fs::create_dir_all(socket_path.parent().expect("worker sockets have a parent"))
102        .map_err(|error| error.to_string())?;
103    let _ = fs::remove_file(socket_path);
104    let listener = UnixListener::bind(socket_path).map_err(|error| error.to_string())?;
105    fs::set_permissions(socket_path, fs::Permissions::from_mode(0o600))
106        .map_err(|error| error.to_string())?;
107    Ok((
108        WorkerCredentials {
109            socket: socket_path.to_path_buf(),
110            token,
111            scope,
112        },
113        listener,
114    ))
115}
116
117pub fn launch_agent<H: StageHooks>(
118    order: StageOrder,
119    hooks: &H,
120    clock: Arc<dyn Clock>,
121    output_observer: Arc<dyn Fn() + Send + Sync>,
122) -> Result<LaunchedAgent, RunnerError<H::Error>> {
123    let StageExecution::Agent(launch) = &order.execution else {
124        return Err(RunnerError::Execution(
125            "agent launch requires an agent stage order".into(),
126        ));
127    };
128    let Some(program) = launch.argv.first() else {
129        return Err(RunnerError::Execution("agent argv is empty".into()));
130    };
131
132    let output_log = RunLogWriter::open(&order.output_path)
133        .map_err(|error| RunnerError::Execution(error.to_string()))?;
134    let worker = launch.worker.clone();
135
136    let mut command = Command::new(program);
137    command
138        .args(&launch.argv[1..])
139        .current_dir(&order.worktree)
140        .envs(launch.environment.iter().cloned())
141        .env("SLOOP_SOCKET", &worker.socket)
142        .env("SLOOP_TOKEN", &worker.token)
143        .stdin(Stdio::null())
144        .stdout(Stdio::piped())
145        .stderr(Stdio::piped())
146        .process_group(0);
147    let mut child = command
148        .spawn()
149        .map_err(|error| RunnerError::Execution(error.to_string()))?;
150    // The agent's own flow stage is recorded alongside its output so the
151    // stage an operator reads about in the flow is the stage they can filter
152    // `sloop logs` by.
153    let readers = vec![
154        spawn_output_reader(
155            child.stdout.take().expect("stdout was piped"),
156            output_log.clone(),
157            OutputSource::Agent,
158            Some(order.stage.clone()),
159            order.attempt,
160            OutputStream::Stdout,
161            Some(clock.clone()),
162            Some(output_observer.clone()),
163        ),
164        spawn_output_reader(
165            child.stderr.take().expect("stderr was piped"),
166            output_log,
167            OutputSource::Agent,
168            Some(order.stage.clone()),
169            order.attempt,
170            OutputStream::Stderr,
171            Some(clock.clone()),
172            Some(output_observer),
173        ),
174    ];
175
176    let pid = child.id();
177    let process = ProcessIdentity {
178        pid,
179        start_time: process_start_time(pid),
180        process_group_id: pid,
181    };
182    let started_at_ms = clock.now_ms();
183    let checkpoint = AgentProcessCheckpoint {
184        run_id: order.run_id,
185        attempt: order.attempt,
186        stage: order.stage,
187        branch: order.branch,
188        worktree: order.worktree,
189        process,
190        worker,
191        started_at_ms,
192    };
193    if let Err(error) = hooks.record_agent_process(&checkpoint) {
194        kill_process_group(process);
195        let _ = child.wait();
196        for reader in readers {
197            let _ = reader.join();
198        }
199        return Err(RunnerError::Hook(error));
200    }
201
202    Ok(LaunchedAgent {
203        child,
204        readers,
205        process,
206        started_at_ms,
207    })
208}
209
210pub fn run_exec_stage<H: StageHooks>(
211    order: &StageOrder,
212    hooks: &H,
213    clock: &dyn Clock,
214) -> Result<ExecutionEvidence, ExecutionFailure<H::Error>> {
215    let started_at_ms = clock.now_ms();
216    let failed = |process, error| ExecutionFailure {
217        evidence: ExecutionEvidence {
218            exit_code: None,
219            started_at_ms,
220            finished_at_ms: clock.now_ms(),
221            process,
222            output_capture_complete: false,
223            stragglers_killed: false,
224        },
225        error,
226    };
227    let StageExecution::Exec(launch) = &order.execution else {
228        return Err(failed(
229            None,
230            RunnerError::Execution("exec launch requires an exec stage order".into()),
231        ));
232    };
233    let Some(program) = launch.argv.first() else {
234        return Err(failed(
235            None,
236            RunnerError::Execution("exec argv is empty".into()),
237        ));
238    };
239    if hooks.cancellation_requested(&order.run_id) {
240        return Err(failed(
241            None,
242            RunnerError::Execution("stage cancelled before launch".into()),
243        ));
244    }
245    let output_log = RunLogWriter::open(&order.output_path).map_err(|error| {
246        failed(
247            None,
248            RunnerError::Execution(format!("cannot open run output: {error}")),
249        )
250    })?;
251
252    let mut command = if launch.worker.is_some() {
253        let mut command = Command::new("sh");
254        command
255            .args([
256                "-c",
257                "IFS= read -r _ || exit 125; exec \"$@\"",
258                "sloop-stage",
259            ])
260            .args(&launch.argv);
261        command
262    } else {
263        let mut command = Command::new(program);
264        command.args(&launch.argv[1..]);
265        command
266    };
267    command
268        .current_dir(&order.worktree)
269        .envs(launch.environment.iter().cloned())
270        .stdout(Stdio::piped())
271        .stderr(Stdio::piped())
272        .process_group(0);
273    if let Some(worker) = &launch.worker {
274        command
275            .env("SLOOP_SOCKET", &worker.socket)
276            .env("SLOOP_TOKEN", &worker.token)
277            .stdin(Stdio::piped());
278    } else {
279        command
280            .env_remove("SLOOP_SOCKET")
281            .env_remove("SLOOP_TOKEN")
282            .stdin(Stdio::null());
283    }
284    let mut child = command
285        .spawn()
286        .map_err(|error| failed(None, RunnerError::Execution(error.to_string())))?;
287    let mut gate = launch
288        .worker
289        .as_ref()
290        .map(|_| child.stdin.take().expect("reported stage stdin was piped"));
291    let pid = child.id();
292    let process = ProcessIdentity {
293        pid,
294        start_time: process_start_time(pid),
295        process_group_id: pid,
296    };
297    let Some(start_time) = process.start_time else {
298        kill_process_group(process);
299        let _ = child.wait();
300        return Err(failed(
301            Some(process),
302            RunnerError::Execution("cannot identify exec process".into()),
303        ));
304    };
305    let readers = vec![
306        spawn_output_reader(
307            child.stdout.take().expect("stdout was piped"),
308            output_log.clone(),
309            OutputSource::Stage,
310            Some(order.stage.clone()),
311            order.attempt,
312            OutputStream::Stdout,
313            None,
314            None,
315        ),
316        spawn_output_reader(
317            child.stderr.take().expect("stderr was piped"),
318            output_log,
319            OutputSource::Stage,
320            Some(order.stage.clone()),
321            order.attempt,
322            OutputStream::Stderr,
323            None,
324            None,
325        ),
326    ];
327    if order.stage == "test" {
328        wait_for_test_hook("before-test-process-checkpoint");
329    }
330    wait_for_test_hook(&format!("before-stage-process-checkpoint-{}", order.stage));
331    let checkpoint = ExecProcessCheckpoint {
332        run_id: order.run_id.clone(),
333        stage: order.stage.clone(),
334        attempt: order.attempt,
335        process: ProcessIdentity {
336            start_time: Some(start_time),
337            ..process
338        },
339        started_at_ms: clock.now_ms(),
340    };
341    if let Err(error) = hooks.record_exec_process(&checkpoint) {
342        kill_process_group_if_matches(checkpoint.process);
343        let _ = child.wait();
344        join_readers(readers);
345        return Err(failed(Some(process), RunnerError::Hook(error)));
346    }
347    wait_for_test_hook(&format!("after-stage-process-checkpoint-{}", order.stage));
348    if hooks.cancellation_requested(&order.run_id) {
349        kill_process_group_if_matches(checkpoint.process);
350        let _ = child.wait();
351        join_readers(readers);
352        return Err(failed(
353            Some(process),
354            RunnerError::Execution("stage cancelled after launch".into()),
355        ));
356    }
357    if let Some(gate) = gate.as_mut()
358        && gate.write_all(b"run\n").is_err()
359    {
360        kill_process_group_if_matches(checkpoint.process);
361        let _ = child.wait();
362        join_readers(readers);
363        return Err(failed(
364            Some(process),
365            RunnerError::Execution("cannot release exec process".into()),
366        ));
367    }
368    drop(gate);
369
370    let status = child.wait();
371    let stragglers_killed = kill_straggler_process_group(pid);
372    let output_capture_complete = join_readers(readers);
373    if !output_capture_complete {
374        return Err(ExecutionFailure {
375            evidence: ExecutionEvidence {
376                exit_code: None,
377                started_at_ms,
378                finished_at_ms: clock.now_ms(),
379                process: Some(process),
380                output_capture_complete: false,
381                stragglers_killed,
382            },
383            error: RunnerError::Execution("exec output capture was incomplete".into()),
384        });
385    }
386    let status = match status {
387        Ok(status) => status,
388        Err(error) => {
389            return Err(ExecutionFailure {
390                evidence: ExecutionEvidence {
391                    exit_code: None,
392                    started_at_ms,
393                    finished_at_ms: clock.now_ms(),
394                    process: Some(process),
395                    output_capture_complete: true,
396                    stragglers_killed,
397                },
398                error: RunnerError::Execution(error.to_string()),
399            });
400        }
401    };
402    Ok(ExecutionEvidence {
403        exit_code: status.code(),
404        started_at_ms,
405        finished_at_ms: clock.now_ms(),
406        process: Some(process),
407        output_capture_complete: true,
408        stragglers_killed,
409    })
410}
411
412#[allow(clippy::too_many_arguments)]
413fn spawn_output_reader(
414    pipe: impl Read + Send + 'static,
415    log: RunLogWriter,
416    source: OutputSource,
417    stage: Option<String>,
418    attempt: u32,
419    stream: OutputStream,
420    clock: Option<Arc<dyn Clock>>,
421    output_observer: Option<Arc<dyn Fn() + Send + Sync>>,
422) -> std::thread::JoinHandle<bool> {
423    std::thread::spawn(move || {
424        let mut pipe = pipe;
425        let mut buffer = [0u8; 8192];
426        loop {
427            match pipe.read(&mut buffer) {
428                Ok(0) => return true,
429                Ok(read) => {
430                    let appended = match clock.as_ref() {
431                        Some(clock) => log.append_at(
432                            clock.now_ms(),
433                            source,
434                            stage.as_deref(),
435                            Some(attempt),
436                            stream,
437                            &buffer[..read],
438                        ),
439                        None => log.append(
440                            source,
441                            stage.as_deref(),
442                            Some(attempt),
443                            stream,
444                            &buffer[..read],
445                        ),
446                    };
447                    if appended.is_err() {
448                        return false;
449                    }
450                    if let Some(observer) = &output_observer {
451                        observer();
452                    }
453                }
454                Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
455                Err(_) => return false,
456            }
457        }
458    })
459}
460
461fn join_readers(readers: Vec<std::thread::JoinHandle<bool>>) -> bool {
462    readers.into_iter().fold(true, |complete, reader| {
463        complete & reader.join().unwrap_or(false)
464    })
465}
466
467fn generate_worker_token() -> Result<String, String> {
468    let mut bytes = [0u8; 32];
469    let mut urandom = fs::File::open("/dev/urandom").map_err(|error| error.to_string())?;
470    urandom
471        .read_exact(&mut bytes)
472        .map_err(|error| error.to_string())?;
473    Ok(BASE64_TOKEN.encode(bytes))
474}
475
476pub fn run_output_path(state_dir: &Path, run_id: &str) -> PathBuf {
477    state_dir.join("runs").join(run_id).join("output.ndjson")
478}
479
480pub fn worker_socket_path(runtime_dir: &Path, run_id: &str) -> PathBuf {
481    // macOS caps Unix socket paths at 104 bytes, and the runtime root under
482    // `$TMPDIR` is long there, so worker sockets sit directly in the runtime
483    // directory and use the short run id. The full 32-hex id pushed a macOS
484    // path past the cap and every agent spawn failed at bind.
485    runtime_dir.join(format!("w{}.sock", crate::run_ref::short(run_id)))
486}
487
488pub fn process_start_time(pid: u32) -> Option<i64> {
489    #[cfg(target_os = "linux")]
490    {
491        let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
492        let after_command = &stat[stat.rfind(')')? + 1..];
493        after_command
494            .split_whitespace()
495            .nth(19)
496            .and_then(|field| field.parse().ok())
497    }
498
499    #[cfg(not(target_os = "linux"))]
500    {
501        let output = Command::new("ps")
502            .args(["-o", "lstart=", "-p", &pid.to_string()])
503            .output()
504            .ok()?;
505        if !output.status.success() || output.stdout.iter().all(u8::is_ascii_whitespace) {
506            return None;
507        }
508        let mut hash = 0xcbf29ce484222325_u64;
509        for byte in output.stdout {
510            hash ^= u64::from(byte);
511            hash = hash.wrapping_mul(0x100000001b3);
512        }
513        Some(hash as i64)
514    }
515}
516
517pub fn process_identity_matches(pid: u32, expected_start_time: Option<i64>) -> bool {
518    matches!(
519        (expected_start_time, process_start_time(pid)),
520        (Some(expected), Some(actual)) if expected == actual
521    )
522}
523
524pub fn kill_straggler_process_group(group: u32) -> bool {
525    let group = -(group as libc::pid_t);
526    let stragglers_present = unsafe { libc::kill(group, 0) } == 0;
527    if stragglers_present {
528        unsafe {
529            libc::kill(group, libc::SIGKILL);
530        }
531    }
532    stragglers_present
533}
534
535fn kill_process_group(process: ProcessIdentity) {
536    unsafe {
537        libc::kill(-(process.process_group_id as libc::pid_t), libc::SIGKILL);
538    }
539}
540
541fn kill_process_group_if_matches(process: ProcessIdentity) {
542    if process_identity_matches(process.pid, process.start_time) {
543        kill_process_group(process);
544    }
545}
546
547#[cfg(debug_assertions)]
548pub fn wait_for_test_hook(name: &str) {
549    use std::time::Duration;
550
551    let Some(directory) = std::env::var_os("SLOOP_TEST_HOOK_DIR").map(PathBuf::from) else {
552        return;
553    };
554    let armed = directory.join(format!("{name}.armed"));
555    if !armed.is_file() {
556        return;
557    }
558    let reached = directory.join(format!("{name}.reached"));
559    let release = directory.join(format!("{name}.release"));
560    if fs::write(&reached, b"").is_err() {
561        return;
562    }
563    while !release.is_file() {
564        std::thread::sleep(Duration::from_millis(10));
565    }
566}
567
568#[cfg(not(debug_assertions))]
569pub fn wait_for_test_hook(_name: &str) {}
570
571#[cfg(test)]
572mod tests {
573    use std::convert::Infallible;
574
575    use tempfile::tempdir;
576
577    use super::run_exec_stage;
578    use crate::clock::SystemClock;
579    use crate::config::{AgentTarget, expand_agent_cmd};
580    use crate::runner::{
581        AgentProcessCheckpoint, ExecLaunch, ExecProcessCheckpoint, StageExecution, StageHooks,
582        StageOrder,
583    };
584
585    struct NoopHooks;
586
587    impl StageHooks for NoopHooks {
588        type Error = Infallible;
589
590        fn cancellation_requested(&self, _run_id: &str) -> bool {
591            false
592        }
593
594        fn record_agent_process(
595            &self,
596            _checkpoint: &AgentProcessCheckpoint,
597        ) -> Result<(), Self::Error> {
598            Ok(())
599        }
600
601        fn record_exec_process(
602            &self,
603            _checkpoint: &ExecProcessCheckpoint,
604        ) -> Result<(), Self::Error> {
605            Ok(())
606        }
607    }
608
609    fn target(cmd: &[&str], model: Option<&str>, effort: Option<&str>) -> AgentTarget {
610        AgentTarget {
611            cmd: cmd.iter().map(|argument| (*argument).to_owned()).collect(),
612            model: model.map(str::to_owned),
613            effort: effort.map(str::to_owned),
614        }
615    }
616
617    #[test]
618    fn agent_command_expands_ticket_model_and_effort() {
619        let template = target(
620            &[
621                "agent",
622                "--model={model}",
623                "--effort",
624                "{effort}",
625                "prompt={prompt}",
626            ],
627            None,
628            None,
629        );
630
631        assert_eq!(
632            expand_agent_cmd(&template, Some("sonnet"), Some("medium"), "assignment").unwrap(),
633            [
634                "agent",
635                "--model=sonnet",
636                "--effort",
637                "medium",
638                "prompt=assignment"
639            ]
640        );
641    }
642
643    #[test]
644    fn agent_command_rejects_a_missing_ticket_field() {
645        let template = target(&["agent", "{model}"], None, Some("medium"));
646
647        assert_eq!(
648            expand_agent_cmd(&template, None, Some("medium"), "assignment"),
649            Err("does not specify `model`".to_owned())
650        );
651    }
652
653    #[test]
654    fn agent_command_falls_back_to_target_defaults() {
655        let template = target(
656            &["agent", "{model}", "{effort}", "{prompt}"],
657            Some("opus"),
658            Some("high"),
659        );
660
661        assert_eq!(
662            expand_agent_cmd(&template, None, None, "assignment").unwrap(),
663            ["agent", "opus", "high", "assignment"]
664        );
665    }
666
667    #[test]
668    fn agent_command_prefers_ticket_values_over_target_defaults() {
669        let template = target(
670            &["agent", "{model}", "{effort}", "{prompt}"],
671            Some("opus"),
672            Some("high"),
673        );
674
675        assert_eq!(
676            expand_agent_cmd(&template, Some("haiku"), Some("low"), "assignment").unwrap(),
677            ["agent", "haiku", "low", "assignment"]
678        );
679    }
680
681    #[test]
682    fn scripted_exec_returns_factual_evidence_without_a_daemon_or_store() {
683        let directory = tempdir().unwrap();
684        let order = StageOrder {
685            run_id: "R1".into(),
686            stage: "check".into(),
687            attempt: 1,
688            execution: StageExecution::Exec(ExecLaunch {
689                argv: vec!["sh".into(), "-c".into(), "printf runner-output".into()],
690                worker: None,
691                environment: Vec::new(),
692            }),
693            worktree: directory.path().into(),
694            branch: "sloop/T1-a1-R1".into(),
695            output_path: directory.path().join("output.ndjson"),
696        };
697
698        let evidence = run_exec_stage(&order, &NoopHooks, &SystemClock).unwrap();
699
700        assert_eq!(evidence.exit_code, Some(0));
701        assert!(evidence.output_capture_complete);
702        assert!(evidence.process.is_some());
703    }
704
705    #[test]
706    fn worker_socket_paths_fit_the_macos_socket_length_cap() {
707        // A representative macOS runtime directory: `$TMPDIR` on macOS is
708        // `/var/folders/<2>/<30>/T/`, followed by the sloop runtime key. The
709        // tightest real layout adds a `tempdir` test prefix on top.
710        let runtime_dir = std::path::Path::new(
711            "/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/.tmpAbC123/sloop/0123456789abcdef",
712        );
713        let socket = super::worker_socket_path(runtime_dir, &"ab".repeat(16));
714
715        assert!(
716            socket.as_os_str().len() <= 104,
717            "worker socket path is {} bytes, over the 104-byte macOS cap: {}",
718            socket.as_os_str().len(),
719            socket.display()
720        );
721    }
722}