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
21pub 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 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 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 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}