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