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    let readers = vec![
151        spawn_output_reader(
152            child.stdout.take().expect("stdout was piped"),
153            output_log.clone(),
154            OutputSource::Agent,
155            Some(order.stage.clone()),
156            order.attempt,
157            OutputStream::Stdout,
158            Some(clock.clone()),
159            Some(output_observer.clone()),
160        ),
161        spawn_output_reader(
162            child.stderr.take().expect("stderr was piped"),
163            output_log,
164            OutputSource::Agent,
165            Some(order.stage.clone()),
166            order.attempt,
167            OutputStream::Stderr,
168            Some(clock.clone()),
169            Some(output_observer),
170        ),
171    ];
172
173    let pid = child.id();
174    let process = ProcessIdentity {
175        pid,
176        start_time: process_start_time(pid),
177        process_group_id: pid,
178    };
179    let started_at_ms = clock.now_ms();
180    let checkpoint = AgentProcessCheckpoint {
181        run_id: order.run_id,
182        attempt: order.attempt,
183        stage: order.stage,
184        branch: order.branch,
185        worktree: order.worktree,
186        process,
187        worker,
188        started_at_ms,
189    };
190    wait_for_test_hook(&format!(
191        "before-stage-process-checkpoint-{}",
192        checkpoint.stage
193    ));
194    if let Err(error) = hooks.record_agent_process(&checkpoint) {
195        kill_process_group(process);
196        let _ = child.wait();
197        for reader in readers {
198            let _ = reader.join();
199        }
200        return Err(RunnerError::Hook(error));
201    }
202
203    Ok(LaunchedAgent {
204        child,
205        readers,
206        process,
207        started_at_ms,
208    })
209}
210
211pub fn run_exec_stage<H: StageHooks>(
212    order: &StageOrder,
213    hooks: &H,
214    clock: &dyn Clock,
215) -> Result<ExecutionEvidence, ExecutionFailure<H::Error>> {
216    let started_at_ms = clock.now_ms();
217    let failed = |process, error| ExecutionFailure {
218        evidence: ExecutionEvidence {
219            exit_code: None,
220            started_at_ms,
221            finished_at_ms: clock.now_ms(),
222            process,
223            output_capture_complete: false,
224            stragglers_killed: false,
225        },
226        error,
227    };
228    let StageExecution::Exec(launch) = &order.execution else {
229        return Err(failed(
230            None,
231            RunnerError::Execution("exec launch requires an exec stage order".into()),
232        ));
233    };
234    let Some(program) = launch.argv.first() else {
235        return Err(failed(
236            None,
237            RunnerError::Execution("exec argv is empty".into()),
238        ));
239    };
240    if hooks.cancellation_requested(&order.run_id) {
241        return Err(failed(
242            None,
243            RunnerError::Execution("stage cancelled before launch".into()),
244        ));
245    }
246    let output_log = RunLogWriter::open(&order.output_path).map_err(|error| {
247        failed(
248            None,
249            RunnerError::Execution(format!("cannot open run output: {error}")),
250        )
251    })?;
252
253    let mut command = if launch.worker.is_some() {
254        let mut command = Command::new("sh");
255        command
256            .args([
257                "-c",
258                "IFS= read -r _ || exit 125; exec \"$@\"",
259                "sloop-stage",
260            ])
261            .args(&launch.argv);
262        command
263    } else {
264        let mut command = Command::new(program);
265        command.args(&launch.argv[1..]);
266        command
267    };
268    command
269        .current_dir(&order.worktree)
270        .envs(launch.environment.iter().cloned())
271        .stdout(Stdio::piped())
272        .stderr(Stdio::piped())
273        .process_group(0);
274    if let Some(worker) = &launch.worker {
275        command
276            .env("SLOOP_SOCKET", &worker.socket)
277            .env("SLOOP_TOKEN", &worker.token)
278            .stdin(Stdio::piped());
279    } else {
280        command
281            .env_remove("SLOOP_SOCKET")
282            .env_remove("SLOOP_TOKEN")
283            .stdin(Stdio::null());
284    }
285    let mut child = command
286        .spawn()
287        .map_err(|error| failed(None, RunnerError::Execution(error.to_string())))?;
288    let mut gate = launch
289        .worker
290        .as_ref()
291        .map(|_| child.stdin.take().expect("reported stage stdin was piped"));
292    let pid = child.id();
293    let process = ProcessIdentity {
294        pid,
295        start_time: process_start_time(pid),
296        process_group_id: pid,
297    };
298    let Some(start_time) = process.start_time else {
299        kill_process_group(process);
300        let _ = child.wait();
301        return Err(failed(
302            Some(process),
303            RunnerError::Execution("cannot identify exec process".into()),
304        ));
305    };
306    let readers = vec![
307        spawn_output_reader(
308            child.stdout.take().expect("stdout was piped"),
309            output_log.clone(),
310            OutputSource::Stage,
311            Some(order.stage.clone()),
312            order.attempt,
313            OutputStream::Stdout,
314            None,
315            None,
316        ),
317        spawn_output_reader(
318            child.stderr.take().expect("stderr was piped"),
319            output_log,
320            OutputSource::Stage,
321            Some(order.stage.clone()),
322            order.attempt,
323            OutputStream::Stderr,
324            None,
325            None,
326        ),
327    ];
328    if order.stage == "test" {
329        wait_for_test_hook("before-test-process-checkpoint");
330    }
331    wait_for_test_hook(&format!("before-stage-process-checkpoint-{}", order.stage));
332    let checkpoint = ExecProcessCheckpoint {
333        run_id: order.run_id.clone(),
334        stage: order.stage.clone(),
335        attempt: order.attempt,
336        process: ProcessIdentity {
337            start_time: Some(start_time),
338            ..process
339        },
340        started_at_ms: clock.now_ms(),
341    };
342    if let Err(error) = hooks.record_exec_process(&checkpoint) {
343        kill_process_group_if_matches(checkpoint.process);
344        let _ = child.wait();
345        join_readers(readers);
346        return Err(failed(Some(process), RunnerError::Hook(error)));
347    }
348    wait_for_test_hook(&format!("after-stage-process-checkpoint-{}", order.stage));
349    if hooks.cancellation_requested(&order.run_id) {
350        kill_process_group_if_matches(checkpoint.process);
351        let _ = child.wait();
352        join_readers(readers);
353        return Err(failed(
354            Some(process),
355            RunnerError::Execution("stage cancelled after launch".into()),
356        ));
357    }
358    if let Some(gate) = gate.as_mut()
359        && gate.write_all(b"run\n").is_err()
360    {
361        kill_process_group_if_matches(checkpoint.process);
362        let _ = child.wait();
363        join_readers(readers);
364        return Err(failed(
365            Some(process),
366            RunnerError::Execution("cannot release exec process".into()),
367        ));
368    }
369    drop(gate);
370
371    let status = child.wait();
372    let stragglers_killed = kill_straggler_process_group(pid);
373    let output_capture_complete = join_readers(readers);
374    if !output_capture_complete {
375        return Err(ExecutionFailure {
376            evidence: ExecutionEvidence {
377                exit_code: None,
378                started_at_ms,
379                finished_at_ms: clock.now_ms(),
380                process: Some(process),
381                output_capture_complete: false,
382                stragglers_killed,
383            },
384            error: RunnerError::Execution("exec output capture was incomplete".into()),
385        });
386    }
387    let status = match status {
388        Ok(status) => status,
389        Err(error) => {
390            return Err(ExecutionFailure {
391                evidence: ExecutionEvidence {
392                    exit_code: None,
393                    started_at_ms,
394                    finished_at_ms: clock.now_ms(),
395                    process: Some(process),
396                    output_capture_complete: true,
397                    stragglers_killed,
398                },
399                error: RunnerError::Execution(error.to_string()),
400            });
401        }
402    };
403    Ok(ExecutionEvidence {
404        exit_code: status.code(),
405        started_at_ms,
406        finished_at_ms: clock.now_ms(),
407        process: Some(process),
408        output_capture_complete: true,
409        stragglers_killed,
410    })
411}
412
413#[allow(clippy::too_many_arguments)]
414fn spawn_output_reader(
415    pipe: impl Read + Send + 'static,
416    log: RunLogWriter,
417    source: OutputSource,
418    stage: Option<String>,
419    attempt: u32,
420    stream: OutputStream,
421    clock: Option<Arc<dyn Clock>>,
422    output_observer: Option<Arc<dyn Fn() + Send + Sync>>,
423) -> std::thread::JoinHandle<bool> {
424    std::thread::spawn(move || {
425        let mut pipe = pipe;
426        let mut buffer = [0u8; 8192];
427        loop {
428            match pipe.read(&mut buffer) {
429                Ok(0) => return true,
430                Ok(read) => {
431                    let appended = match clock.as_ref() {
432                        Some(clock) => log.append_at(
433                            clock.now_ms(),
434                            source,
435                            stage.as_deref(),
436                            Some(attempt),
437                            stream,
438                            &buffer[..read],
439                        ),
440                        None => log.append(
441                            source,
442                            stage.as_deref(),
443                            Some(attempt),
444                            stream,
445                            &buffer[..read],
446                        ),
447                    };
448                    if appended.is_err() {
449                        return false;
450                    }
451                    if let Some(observer) = &output_observer {
452                        observer();
453                    }
454                }
455                Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
456                Err(_) => return false,
457            }
458        }
459    })
460}
461
462fn join_readers(readers: Vec<std::thread::JoinHandle<bool>>) -> bool {
463    readers.into_iter().fold(true, |complete, reader| {
464        complete & reader.join().unwrap_or(false)
465    })
466}
467
468fn generate_worker_token() -> Result<String, String> {
469    let mut bytes = [0u8; 32];
470    let mut urandom = fs::File::open("/dev/urandom").map_err(|error| error.to_string())?;
471    urandom
472        .read_exact(&mut bytes)
473        .map_err(|error| error.to_string())?;
474    Ok(BASE64_TOKEN.encode(bytes))
475}
476
477pub fn run_output_path(state_dir: &Path, run_id: &str) -> PathBuf {
478    state_dir.join("runs").join(run_id).join("output.ndjson")
479}
480
481pub fn worker_socket_path(runtime_dir: &Path, run_id: &str) -> PathBuf {
482    // macOS caps Unix socket paths at 104 bytes, and the runtime root under
483    // `$TMPDIR` is long there, so worker sockets sit directly in the runtime
484    // directory and use the short run id. The full 32-hex id pushed a macOS
485    // path past the cap and every agent spawn failed at bind.
486    runtime_dir.join(format!("w{}.sock", crate::run_ref::short(run_id)))
487}
488
489pub fn process_start_time(pid: u32) -> Option<i64> {
490    #[cfg(target_os = "linux")]
491    {
492        let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
493        let after_command = &stat[stat.rfind(')')? + 1..];
494        after_command
495            .split_whitespace()
496            .nth(19)
497            .and_then(|field| field.parse().ok())
498    }
499
500    #[cfg(not(target_os = "linux"))]
501    {
502        let output = Command::new("ps")
503            .args(["-o", "lstart=", "-p", &pid.to_string()])
504            .output()
505            .ok()?;
506        if !output.status.success() || output.stdout.iter().all(u8::is_ascii_whitespace) {
507            return None;
508        }
509        let mut hash = 0xcbf29ce484222325_u64;
510        for byte in output.stdout {
511            hash ^= u64::from(byte);
512            hash = hash.wrapping_mul(0x100000001b3);
513        }
514        Some(hash as i64)
515    }
516}
517
518pub fn process_identity_matches(pid: u32, expected_start_time: Option<i64>) -> bool {
519    matches!(
520        (expected_start_time, process_start_time(pid)),
521        (Some(expected), Some(actual)) if expected == actual
522    )
523}
524
525pub fn kill_straggler_process_group(group: u32) -> bool {
526    let group = -(group as libc::pid_t);
527    let stragglers_present = unsafe { libc::kill(group, 0) } == 0;
528    if stragglers_present {
529        unsafe {
530            libc::kill(group, libc::SIGKILL);
531        }
532    }
533    stragglers_present
534}
535
536fn kill_process_group(process: ProcessIdentity) {
537    unsafe {
538        libc::kill(-(process.process_group_id as libc::pid_t), libc::SIGKILL);
539    }
540}
541
542fn kill_process_group_if_matches(process: ProcessIdentity) {
543    if process_identity_matches(process.pid, process.start_time) {
544        kill_process_group(process);
545    }
546}
547
548#[cfg(debug_assertions)]
549pub fn wait_for_test_hook(name: &str) {
550    use std::time::Duration;
551
552    let Some(directory) = std::env::var_os("SLOOP_TEST_HOOK_DIR").map(PathBuf::from) else {
553        return;
554    };
555    let armed = directory.join(format!("{name}.armed"));
556    if !armed.is_file() {
557        return;
558    }
559    let reached = directory.join(format!("{name}.reached"));
560    let release = directory.join(format!("{name}.release"));
561    if fs::write(&reached, b"").is_err() {
562        return;
563    }
564    while !release.is_file() {
565        std::thread::sleep(Duration::from_millis(10));
566    }
567}
568
569#[cfg(not(debug_assertions))]
570pub fn wait_for_test_hook(_name: &str) {}
571
572#[cfg(test)]
573mod tests {
574    use std::convert::Infallible;
575
576    use tempfile::tempdir;
577
578    use super::run_exec_stage;
579    use crate::clock::SystemClock;
580    use crate::config::{AgentTarget, expand_agent_cmd};
581    use crate::runner::{
582        AgentProcessCheckpoint, ExecLaunch, ExecProcessCheckpoint, StageExecution, StageHooks,
583        StageOrder,
584    };
585
586    struct NoopHooks;
587
588    impl StageHooks for NoopHooks {
589        type Error = Infallible;
590
591        fn cancellation_requested(&self, _run_id: &str) -> bool {
592            false
593        }
594
595        fn record_agent_process(
596            &self,
597            _checkpoint: &AgentProcessCheckpoint,
598        ) -> Result<(), Self::Error> {
599            Ok(())
600        }
601
602        fn record_exec_process(
603            &self,
604            _checkpoint: &ExecProcessCheckpoint,
605        ) -> Result<(), Self::Error> {
606            Ok(())
607        }
608    }
609
610    fn target(cmd: &[&str], model: Option<&str>, effort: Option<&str>) -> AgentTarget {
611        AgentTarget {
612            cmd: cmd.iter().map(|argument| (*argument).to_owned()).collect(),
613            model: model.map(str::to_owned),
614            effort: effort.map(str::to_owned),
615        }
616    }
617
618    #[test]
619    fn agent_command_expands_ticket_model_and_effort() {
620        let template = target(
621            &[
622                "agent",
623                "--model={model}",
624                "--effort",
625                "{effort}",
626                "prompt={prompt}",
627            ],
628            None,
629            None,
630        );
631
632        assert_eq!(
633            expand_agent_cmd(&template, Some("sonnet"), Some("medium"), "assignment").unwrap(),
634            [
635                "agent",
636                "--model=sonnet",
637                "--effort",
638                "medium",
639                "prompt=assignment"
640            ]
641        );
642    }
643
644    #[test]
645    fn agent_command_rejects_a_missing_ticket_field() {
646        let template = target(&["agent", "{model}"], None, Some("medium"));
647
648        assert_eq!(
649            expand_agent_cmd(&template, None, Some("medium"), "assignment"),
650            Err("does not specify `model`".to_owned())
651        );
652    }
653
654    #[test]
655    fn agent_command_falls_back_to_target_defaults() {
656        let template = target(
657            &["agent", "{model}", "{effort}", "{prompt}"],
658            Some("opus"),
659            Some("high"),
660        );
661
662        assert_eq!(
663            expand_agent_cmd(&template, None, None, "assignment").unwrap(),
664            ["agent", "opus", "high", "assignment"]
665        );
666    }
667
668    #[test]
669    fn agent_command_prefers_ticket_values_over_target_defaults() {
670        let template = target(
671            &["agent", "{model}", "{effort}", "{prompt}"],
672            Some("opus"),
673            Some("high"),
674        );
675
676        assert_eq!(
677            expand_agent_cmd(&template, Some("haiku"), Some("low"), "assignment").unwrap(),
678            ["agent", "haiku", "low", "assignment"]
679        );
680    }
681
682    #[test]
683    fn scripted_exec_returns_factual_evidence_without_a_daemon_or_store() {
684        let directory = tempdir().unwrap();
685        let order = StageOrder {
686            run_id: "R1".into(),
687            stage: "check".into(),
688            attempt: 1,
689            execution: StageExecution::Exec(ExecLaunch {
690                argv: vec!["sh".into(), "-c".into(), "printf runner-output".into()],
691                worker: None,
692                environment: Vec::new(),
693            }),
694            worktree: directory.path().into(),
695            branch: "sloop/T1-a1-R1".into(),
696            output_path: directory.path().join("output.ndjson"),
697        };
698
699        let evidence = run_exec_stage(&order, &NoopHooks, &SystemClock).unwrap();
700
701        assert_eq!(evidence.exit_code, Some(0));
702        assert!(evidence.output_capture_complete);
703        assert!(evidence.process.is_some());
704    }
705
706    #[test]
707    fn worker_socket_paths_fit_the_macos_socket_length_cap() {
708        let runtime_dir = std::path::Path::new(
709            "/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/.tmpAbC123/sloop/0123456789abcdef",
710        );
711        let socket = super::worker_socket_path(runtime_dir, &"ab".repeat(16));
712
713        assert!(
714            socket.as_os_str().len() <= 104,
715            "worker socket path is {} bytes, over the 104-byte macOS cap: {}",
716            socket.as_os_str().len(),
717            socket.display()
718        );
719    }
720}