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
21pub 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
64pub 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
93pub 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![
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 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 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}