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![
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 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}