1use std::{
15 collections::{HashMap, HashSet},
16 ffi::OsString,
17 io::Write as _,
18 path::{Path, PathBuf},
19 sync::{
20 Arc, Mutex,
21 atomic::{AtomicBool, Ordering},
22 },
23 time::{Duration, SystemTime, UNIX_EPOCH},
24};
25
26use serde::{Deserialize, Serialize};
27use sha2::{Digest, Sha256};
28
29pub const PARENT_VARIABLE: &str = "SCV_PARENT";
31pub const DEPTH_VARIABLE: &str = "SCV_DELEGATION_DEPTH";
33const STOP_GRACE: Duration = Duration::from_secs(2);
35const MAX_RECORD_BYTES: u64 = 64 * 1024;
37const ZOMBIE_MIN_AGE: Duration = Duration::from_secs(10);
39
40pub fn current_depth() -> u32 {
42 std::env::var(DEPTH_VARIABLE)
43 .ok()
44 .and_then(|value| value.trim().parse().ok())
45 .unwrap_or(0)
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ProcessIdentity {
51 pub pid: u32,
52 pub start_time: u64,
53}
54
55impl ProcessIdentity {
56 pub fn current() -> Option<Self> {
57 Self::of(std::process::id())
58 }
59
60 pub fn of(pid: u32) -> Option<Self> {
61 process_start_time(pid).map(|start_time| Self { pid, start_time })
62 }
63
64 pub fn is_alive(&self) -> bool {
67 #[cfg(target_os = "linux")]
68 {
69 linux::stat(self.pid)
70 .is_some_and(|info| info.start_time == self.start_time && info.state != 'Z')
71 }
72 #[cfg(not(target_os = "linux"))]
73 {
74 Self::of(self.pid) == Some(*self)
75 }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct DelegationRecord {
82 pub handle: String,
83 pub agent: String,
84 pub instance: String,
85 pub session: String,
86 pub owner: ProcessIdentity,
87 pub process: ProcessIdentity,
89 pub pgid: u32,
90 pub cwd: PathBuf,
91 pub started_unix: u64,
92 pub depth: u32,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub conversation: Option<String>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub turn: Option<u32>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct DelegationEntry {
104 pub record: DelegationRecord,
105 pub orphaned: bool,
107 pub processes: usize,
109}
110
111#[derive(Debug, Clone, Default, PartialEq, Eq)]
113pub struct ReconcileReport {
114 pub reaped: Vec<String>,
116 pub removed: usize,
118}
119
120#[derive(Debug, Default)]
121struct Inner {
122 active: HashMap<String, Arc<AtomicBool>>,
123 reaped: u64,
124}
125
126#[derive(Debug)]
128pub struct DelegationRegistry {
129 record_dir: PathBuf,
130 instance: String,
131 owner: Option<ProcessIdentity>,
132 depth: u32,
133 chain: Option<String>,
134 inner: Mutex<Inner>,
135}
136
137pub(crate) struct PendingDelegation {
139 pub handle: String,
140 pub environment: Vec<(OsString, OsString)>,
141 agent: String,
142 session: String,
143 cwd: PathBuf,
144 conversation: Option<(String, u32)>,
145}
146
147impl DelegationRegistry {
148 pub fn new(instance_home: &Path) -> Self {
150 let digest = Sha256::digest(instance_home.as_os_str().as_encoded_bytes());
151 let instance = digest[..4]
152 .iter()
153 .map(|byte| format!("{byte:02x}"))
154 .collect();
155 Self {
156 record_dir: instance_home.join("run").join("delegations"),
157 instance,
158 owner: ProcessIdentity::current(),
159 depth: current_depth(),
160 chain: std::env::var(PARENT_VARIABLE)
161 .ok()
162 .filter(|value| !value.trim().is_empty()),
163 inner: Mutex::new(Inner::default()),
164 }
165 }
166
167 pub fn depth(&self) -> u32 {
169 self.depth
170 }
171
172 pub fn record_dir(&self) -> &Path {
173 &self.record_dir
174 }
175
176 pub fn instance(&self) -> &str {
178 &self.instance
179 }
180
181 pub fn reaped_total(&self) -> u64 {
183 self.inner.lock().expect("registry lock").reaped
184 }
185
186 pub fn conversation_dir(&self) -> PathBuf {
188 self.record_dir.parent().map_or_else(
189 || self.record_dir.join("conversations"),
190 |run| run.join("conversations"),
191 )
192 }
193
194 pub(crate) fn begin(
195 &self,
196 agent: &str,
197 session: &str,
198 cwd: &Path,
199 conversation: Option<(&str, u32)>,
200 ) -> PendingDelegation {
201 let suffix = uuid::Uuid::new_v4().simple().to_string();
202 let handle = format!("{agent}-{}", &suffix[..6]);
203 let entry = format!("{}/{session}/{handle}", self.instance);
204 let chain = match &self.chain {
205 Some(chain) => format!("{chain};{entry}"),
206 None => entry,
207 };
208 PendingDelegation {
209 environment: vec![
210 (PARENT_VARIABLE.into(), chain.into()),
211 (DEPTH_VARIABLE.into(), (self.depth + 1).to_string().into()),
212 ],
213 handle,
214 agent: agent.to_owned(),
215 session: session.to_owned(),
216 cwd: cwd.to_owned(),
217 conversation: conversation.map(|(handle, turn)| (handle.to_owned(), turn)),
218 }
219 }
220
221 pub(crate) fn register(
224 self: &Arc<Self>,
225 pending: PendingDelegation,
226 pid: u32,
227 ) -> std::io::Result<DelegationGuard> {
228 let killed = Arc::new(AtomicBool::new(false));
229 let record = DelegationRecord {
230 handle: pending.handle.clone(),
231 agent: pending.agent,
232 instance: self.instance.clone(),
233 session: pending.session,
234 owner: self.owner.unwrap_or(ProcessIdentity {
235 pid: std::process::id(),
236 start_time: 0,
237 }),
238 process: ProcessIdentity::of(pid).unwrap_or(ProcessIdentity { pid, start_time: 0 }),
239 pgid: pid,
240 cwd: pending.cwd,
241 started_unix: SystemTime::now()
242 .duration_since(UNIX_EPOCH)
243 .map_or(0, |elapsed| elapsed.as_secs()),
244 depth: self.depth + 1,
245 conversation: pending
246 .conversation
247 .as_ref()
248 .map(|(handle, _)| handle.clone()),
249 turn: pending.conversation.as_ref().map(|(_, turn)| *turn),
250 };
251 self.inner
252 .lock()
253 .expect("registry lock")
254 .active
255 .insert(record.handle.clone(), Arc::clone(&killed));
256 if let Err(error) = write_record(&self.record_dir, &record) {
257 self.inner
258 .lock()
259 .expect("registry lock")
260 .active
261 .remove(&record.handle);
262 return Err(error);
263 }
264 Ok(DelegationGuard {
265 registry: Arc::clone(self),
266 handle: record.handle,
267 pgid: pid,
268 killed,
269 finished: false,
270 })
271 }
272
273 pub fn list(&self, include_orphans: bool) -> Vec<DelegationEntry> {
276 let table = ProcessTable::snapshot();
277 let mut entries: Vec<_> = self
278 .records()
279 .into_iter()
280 .filter_map(|record| {
281 let orphaned = !self.owner_alive(&record);
282 if orphaned && !include_orphans {
283 return None;
284 }
285 let processes = table.members(&record).len();
286 Some(DelegationEntry {
287 record,
288 orphaned,
289 processes,
290 })
291 })
292 .collect();
293 entries.sort_by(|a, b| {
294 a.record
295 .started_unix
296 .cmp(&b.record.started_unix)
297 .then_with(|| a.record.handle.cmp(&b.record.handle))
298 });
299 entries
300 }
301
302 pub async fn kill(&self, handle: &str) -> Result<(), String> {
304 let record = self
305 .records()
306 .into_iter()
307 .find(|record| record.handle == handle)
308 .ok_or_else(|| format!("no running delegation {handle:?}"))?;
309 let local = self
310 .inner
311 .lock()
312 .expect("registry lock")
313 .active
314 .get(handle)
315 .cloned();
316 if let Some(killed) = &local {
317 killed.store(true, Ordering::Release);
318 }
319 stop_delegation(&record).await;
320 if local.is_none() && !self.owner_alive(&record) {
321 remove_record(&self.record_dir, handle);
322 self.inner.lock().expect("registry lock").reaped += 1;
323 }
324 Ok(())
325 }
326
327 pub async fn reconcile(&self) -> ReconcileReport {
329 let mut report = ReconcileReport::default();
330 for record in self.records() {
331 if self.owner_alive(&record) {
332 continue;
333 }
334 if stop_delegation(&record).await {
335 report.reaped.push(record.handle.clone());
336 } else {
337 report.removed += 1;
338 }
339 remove_record(&self.record_dir, &record.handle);
340 }
341 self.inner.lock().expect("registry lock").reaped += report.reaped.len() as u64;
342 report
343 }
344
345 fn owner_alive(&self, record: &DelegationRecord) -> bool {
348 if Some(record.owner) == self.owner {
349 return self
350 .inner
351 .lock()
352 .expect("registry lock")
353 .active
354 .contains_key(&record.handle);
355 }
356 record.owner.is_alive()
357 }
358
359 fn records(&self) -> Vec<DelegationRecord> {
360 let Ok(entries) = std::fs::read_dir(&self.record_dir) else {
361 return Vec::new();
362 };
363 entries
364 .filter_map(Result::ok)
365 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
366 .filter_map(|entry| read_record(&entry.path()))
367 .filter(|record| record.instance == self.instance)
368 .collect()
369 }
370
371 fn finish_local(&self, handle: &str) {
372 self.inner
373 .lock()
374 .expect("registry lock")
375 .active
376 .remove(handle);
377 remove_record(&self.record_dir, handle);
378 }
379}
380
381pub(crate) struct DelegationGuard {
383 registry: Arc<DelegationRegistry>,
384 handle: String,
385 pgid: u32,
386 killed: Arc<AtomicBool>,
387 finished: bool,
388}
389
390impl DelegationGuard {
391 #[cfg(test)]
392 pub(crate) fn handle(&self) -> &str {
393 &self.handle
394 }
395
396 pub(crate) fn was_killed(&self) -> bool {
398 self.killed.load(Ordering::Acquire)
399 }
400
401 pub(crate) async fn finish(mut self) {
403 self.finished = true;
404 stop_tagged(&self.handle).await;
405 self.registry.finish_local(&self.handle);
406 }
407}
408
409impl Drop for DelegationGuard {
410 fn drop(&mut self) {
411 if self.finished {
412 return;
413 }
414 signal_group(self.pgid, libc::SIGKILL);
417 self.registry.finish_local(&self.handle);
418 let handle = self.handle.clone();
419 if let Ok(runtime) = tokio::runtime::Handle::try_current() {
420 runtime.spawn(async move { stop_tagged(&handle).await });
421 } else {
422 for identity in tagged_processes(&handle) {
423 signal(identity.pid, libc::SIGKILL);
424 }
425 }
426 }
427}
428
429async fn stop_delegation(record: &DelegationRecord) -> bool {
432 let mut stopped = false;
433 let leader = ProcessIdentity::of(record.process.pid);
437 let group_is_ours = record.pgid == record.process.pid
438 && match leader {
439 Some(leader) => leader == record.process,
440 None => group_exists(record.pgid),
441 };
442 if group_is_ours && group_exists(record.pgid) {
443 stopped = true;
444 signal_group(record.pgid, libc::SIGTERM);
445 let deadline = tokio::time::Instant::now() + STOP_GRACE;
446 while group_exists(record.pgid) && tokio::time::Instant::now() < deadline {
447 tokio::time::sleep(Duration::from_millis(50)).await;
448 }
449 signal_group(record.pgid, libc::SIGKILL);
450 }
451 stopped | stop_tagged(&record.handle).await
452}
453
454async fn stop_tagged(handle: &str) -> bool {
456 let tagged = tagged_processes(handle);
457 if tagged.is_empty() {
458 return false;
459 }
460 for identity in &tagged {
461 signal(identity.pid, libc::SIGTERM);
462 }
463 let deadline = tokio::time::Instant::now() + STOP_GRACE;
464 while tagged.iter().any(ProcessIdentity::is_alive) && tokio::time::Instant::now() < deadline {
465 tokio::time::sleep(Duration::from_millis(50)).await;
466 }
467 for identity in tagged.iter().filter(|identity| identity.is_alive()) {
468 signal(identity.pid, libc::SIGKILL);
469 }
470 true
471}
472
473fn tagged_processes(handle: &str) -> Vec<ProcessIdentity> {
475 let own = std::process::id();
476 ProcessTable::snapshot()
477 .tagged
478 .into_iter()
479 .filter(|(identity, chain)| identity.pid != own && chain_names(chain, handle))
480 .map(|(identity, _)| identity)
481 .collect()
482}
483
484fn chain_names(chain: &str, handle: &str) -> bool {
485 chain
486 .split(';')
487 .any(|entry| entry.rsplit('/').next() == Some(handle))
488}
489
490fn signal(pid: u32, signal: i32) {
491 if let Ok(pid) = i32::try_from(pid)
492 && pid > 0
493 {
494 unsafe {
495 libc::kill(pid, signal);
496 }
497 }
498}
499
500fn signal_group(pgid: u32, signal: i32) {
501 if let Ok(pgid) = i32::try_from(pgid)
503 && pgid > 1
504 {
505 unsafe {
506 libc::kill(-pgid, signal);
507 }
508 }
509}
510
511fn group_exists(pgid: u32) -> bool {
513 let Ok(group) = i32::try_from(pgid) else {
514 return false;
515 };
516 if group <= 1 {
517 return false;
518 }
519 let result = unsafe { libc::kill(-group, 0) };
520 let signalable =
521 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
522 #[cfg(target_os = "linux")]
523 {
524 signalable
525 && linux::all_stats()
526 .iter()
527 .any(|info| info.pgid == pgid && info.state != 'Z')
528 }
529 #[cfg(not(target_os = "linux"))]
530 {
531 signalable
532 }
533}
534
535fn write_record(dir: &Path, record: &DelegationRecord) -> std::io::Result<()> {
536 write_private_json(dir, &format!("{}.json", record.handle), record)
537}
538
539pub(crate) fn write_private_json(
542 dir: &Path,
543 name: &str,
544 value: &impl Serialize,
545) -> std::io::Result<()> {
546 use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
547 std::fs::create_dir_all(dir)?;
548 if let Some(run) = dir.parent() {
549 std::fs::set_permissions(run, std::fs::Permissions::from_mode(0o700))?;
550 }
551 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
552 let bytes = serde_json::to_vec_pretty(value).map_err(std::io::Error::other)?;
553 let temporary = dir.join(format!(".{name}.tmp"));
554 let mut file = std::fs::OpenOptions::new()
555 .write(true)
556 .create(true)
557 .truncate(true)
558 .mode(0o600)
559 .open(&temporary)?;
560 file.write_all(&bytes)?;
561 file.sync_all()?;
562 drop(file);
563 std::fs::rename(&temporary, dir.join(name))
564}
565
566fn read_record(path: &Path) -> Option<DelegationRecord> {
567 let file = std::fs::File::open(path).ok()?;
568 let mut bytes = Vec::new();
569 std::io::Read::read_to_end(&mut std::io::Read::take(file, MAX_RECORD_BYTES), &mut bytes)
570 .ok()?;
571 let record: DelegationRecord = serde_json::from_slice(&bytes).ok()?;
572 (path.file_stem().and_then(|stem| stem.to_str()) == Some(record.handle.as_str()))
574 .then_some(record)
575}
576
577fn remove_record(dir: &Path, handle: &str) {
578 let _ = std::fs::remove_file(dir.join(format!("{handle}.json")));
579}
580
581pub fn become_child_subreaper() -> bool {
584 #[cfg(target_os = "linux")]
585 {
586 unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) == 0 }
587 }
588 #[cfg(not(target_os = "linux"))]
589 {
590 false
591 }
592}
593
594static SPAWNED: Mutex<Option<HashSet<u32>>> = Mutex::new(None);
595
596pub(crate) fn track_spawned(pid: u32) {
598 SPAWNED
599 .lock()
600 .expect("spawned lock")
601 .get_or_insert_with(HashSet::new)
602 .insert(pid);
603}
604
605pub(crate) fn untrack_spawned(pid: u32) {
606 if let Some(spawned) = SPAWNED.lock().expect("spawned lock").as_mut() {
607 spawned.remove(&pid);
608 }
609}
610
611pub fn reap_orphaned_zombies() -> usize {
614 #[cfg(target_os = "linux")]
615 {
616 let own = std::process::id();
617 let spawned = SPAWNED
618 .lock()
619 .expect("spawned lock")
620 .clone()
621 .unwrap_or_default();
622 let uptime = linux::uptime_ticks();
623 let mut reaped = 0;
624 for info in linux::all_stats() {
625 if info.ppid != own || info.state != 'Z' || spawned.contains(&info.pid) {
626 continue;
627 }
628 let old_enough = uptime.is_some_and(|now| {
629 now.saturating_sub(info.start_time)
630 >= ZOMBIE_MIN_AGE.as_secs() * linux::clock_ticks()
631 });
632 if !old_enough {
633 continue;
634 }
635 let mut status = 0;
636 if unsafe { libc::waitpid(info.pid as i32, &mut status, libc::WNOHANG) }
637 == info.pid as i32
638 {
639 reaped += 1;
640 }
641 }
642 reaped
643 }
644 #[cfg(not(target_os = "linux"))]
645 {
646 0
647 }
648}
649
650struct ProcessTable {
652 groups: Vec<(ProcessIdentity, u32)>,
653 tagged: Vec<(ProcessIdentity, String)>,
654}
655
656impl ProcessTable {
657 fn members(&self, record: &DelegationRecord) -> HashSet<u32> {
658 let mut members: HashSet<u32> = self
659 .groups
660 .iter()
661 .filter(|(_, pgid)| *pgid == record.pgid)
662 .map(|(identity, _)| identity.pid)
663 .collect();
664 members.extend(
665 self.tagged
666 .iter()
667 .filter(|(_, chain)| chain_names(chain, &record.handle))
668 .map(|(identity, _)| identity.pid),
669 );
670 members
671 }
672
673 #[cfg(target_os = "linux")]
674 fn snapshot() -> Self {
675 let mut groups = Vec::new();
676 let mut tagged = Vec::new();
677 for info in linux::all_stats() {
678 if info.state == 'Z' {
679 continue;
680 }
681 let identity = ProcessIdentity {
682 pid: info.pid,
683 start_time: info.start_time,
684 };
685 groups.push((identity, info.pgid));
686 if let Some(chain) = linux::parent_chain(info.pid) {
687 tagged.push((identity, chain));
688 }
689 }
690 Self { groups, tagged }
691 }
692
693 #[cfg(not(target_os = "linux"))]
694 fn snapshot() -> Self {
695 let mut groups = Vec::new();
696 let mut tagged = Vec::new();
697 let Ok(output) = std::process::Command::new("ps")
699 .args(["-E", "-ww", "-axo", "pid=,pgid=,command="])
700 .output()
701 else {
702 return Self { groups, tagged };
703 };
704 for line in String::from_utf8_lossy(&output.stdout).lines() {
705 let mut fields = line.split_whitespace();
706 let (Some(pid), Some(pgid)) = (
707 fields.next().and_then(|value| value.parse::<u32>().ok()),
708 fields.next().and_then(|value| value.parse::<u32>().ok()),
709 ) else {
710 continue;
711 };
712 let Some(identity) = ProcessIdentity::of(pid) else {
713 continue;
714 };
715 groups.push((identity, pgid));
716 if let Some(chain) = fields.find_map(|field| {
717 field
718 .strip_prefix(PARENT_VARIABLE)
719 .and_then(|rest| rest.strip_prefix('='))
720 }) {
721 tagged.push((identity, chain.to_owned()));
722 }
723 }
724 Self { groups, tagged }
725 }
726}
727
728#[cfg(target_os = "linux")]
729fn process_start_time(pid: u32) -> Option<u64> {
730 linux::stat(pid).map(|info| info.start_time)
731}
732
733#[cfg(target_os = "macos")]
734fn process_start_time(pid: u32) -> Option<u64> {
735 let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
736 let size = std::mem::size_of::<libc::proc_bsdinfo>() as i32;
737 let written = unsafe {
738 libc::proc_pidinfo(
739 pid as i32,
740 libc::PROC_PIDTBSDINFO,
741 0,
742 (&mut info as *mut libc::proc_bsdinfo).cast(),
743 size,
744 )
745 };
746 (written == size).then(|| info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec)
747}
748
749#[cfg(not(any(target_os = "linux", target_os = "macos")))]
750fn process_start_time(pid: u32) -> Option<u64> {
751 let alive = unsafe { libc::kill(pid as i32, 0) } == 0;
752 alive.then_some(0)
753}
754
755#[cfg(target_os = "linux")]
756mod linux {
757 pub(super) struct Stat {
758 pub pid: u32,
759 pub ppid: u32,
760 pub pgid: u32,
761 pub state: char,
762 pub start_time: u64,
763 }
764
765 pub(super) fn stat(pid: u32) -> Option<Stat> {
766 let text = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
767 let rest = &text[text.rfind(')')? + 2..];
769 let fields: Vec<&str> = rest.split_whitespace().collect();
770 Some(Stat {
772 pid,
773 state: fields.first()?.chars().next()?,
774 ppid: fields.get(1)?.parse().ok()?,
775 pgid: fields.get(2)?.parse().ok()?,
776 start_time: fields.get(19)?.parse().ok()?,
777 })
778 }
779
780 pub(super) fn all_stats() -> Vec<Stat> {
781 let Ok(entries) = std::fs::read_dir("/proc") else {
782 return Vec::new();
783 };
784 entries
785 .filter_map(Result::ok)
786 .filter_map(|entry| entry.file_name().to_str()?.parse::<u32>().ok())
787 .filter_map(stat)
788 .collect()
789 }
790
791 pub(super) fn parent_chain(pid: u32) -> Option<String> {
793 let environ = std::fs::read(format!("/proc/{pid}/environ")).ok()?;
794 let prefix = format!("{}=", super::PARENT_VARIABLE);
795 environ.split(|byte| *byte == 0).find_map(|entry| {
796 entry
797 .strip_prefix(prefix.as_bytes())
798 .map(|value| String::from_utf8_lossy(value).into_owned())
799 })
800 }
801
802 pub(super) fn clock_ticks() -> u64 {
803 let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
804 u64::try_from(ticks)
805 .ok()
806 .filter(|ticks| *ticks > 0)
807 .unwrap_or(100)
808 }
809
810 pub(super) fn uptime_ticks() -> Option<u64> {
811 let text = std::fs::read_to_string("/proc/uptime").ok()?;
812 let seconds: f64 = text.split_whitespace().next()?.parse().ok()?;
813 Some((seconds * clock_ticks() as f64) as u64)
814 }
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820 use std::os::unix::{fs::PermissionsExt as _, process::CommandExt as _};
821
822 fn registry(home: &Path) -> Arc<DelegationRegistry> {
823 Arc::new(DelegationRegistry::new(home))
824 }
825
826 fn spawn_tagged(script: &str, environment: &[(OsString, OsString)]) -> std::process::Child {
828 std::process::Command::new("sh")
829 .args(["-c", script])
830 .envs(environment.iter().map(|(key, value)| (key, value)))
831 .stdin(std::process::Stdio::null())
832 .stdout(std::process::Stdio::null())
833 .stderr(std::process::Stdio::null())
834 .process_group(0)
835 .spawn()
836 .unwrap()
837 }
838
839 async fn wait_for(mut condition: impl FnMut() -> bool) -> bool {
840 for _ in 0..200 {
841 if condition() {
842 return true;
843 }
844 tokio::time::sleep(Duration::from_millis(25)).await;
845 }
846 false
847 }
848
849 #[test]
850 fn chains_match_only_their_own_handle() {
851 assert!(chain_names("abcd/s1/codex-1a2b3c", "codex-1a2b3c"));
852 assert!(chain_names(
853 "x/s/claude-000000;abcd/s1/codex-1a2b3c",
854 "codex-1a2b3c"
855 ));
856 assert!(!chain_names("abcd/s1/codex-1a2b3c", "codex-1a2b3"));
857 assert!(!chain_names("abcd/s1/codex-1a2b3c", "1a2b3c"));
858 }
859
860 #[test]
861 fn nested_tags_extend_the_chain_and_depth() {
862 let home = tempfile::tempdir().unwrap();
863 let mut registry = DelegationRegistry::new(home.path());
864 registry.chain = Some("aaaa/s0/codex-111111".into());
865 registry.depth = 1;
866 let pending = registry.begin("claude", "s1", home.path(), Some(("claude-1", 2)));
867 let value = |name: &str| {
868 pending
869 .environment
870 .iter()
871 .find(|(key, _)| key == name)
872 .map(|(_, value)| value.to_str().unwrap().to_owned())
873 .unwrap()
874 };
875 assert_eq!(
876 value(PARENT_VARIABLE),
877 format!(
878 "aaaa/s0/codex-111111;{}/s1/{}",
879 registry.instance, pending.handle
880 )
881 );
882 assert_eq!(value(DEPTH_VARIABLE), "2");
883 assert!(pending.handle.starts_with("claude-"));
884 }
885
886 #[tokio::test]
887 async fn records_are_private_and_removed_when_the_run_finishes() {
888 let home = tempfile::tempdir().unwrap();
889 let registry = registry(home.path());
890 let pending = registry.begin("codex", "session", home.path(), None);
891 let environment = pending.environment.clone();
892 let mut child = spawn_tagged("sleep 30", &environment);
893 let guard = registry.register(pending, child.id()).unwrap();
894 let path = registry
895 .record_dir()
896 .join(format!("{}.json", guard.handle()));
897 let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
898 assert_eq!(mode(&path), 0o600);
899 assert_eq!(mode(registry.record_dir()), 0o700);
900 assert_eq!(mode(registry.record_dir().parent().unwrap()), 0o700);
901
902 let listed = registry.list(false);
903 assert_eq!(listed.len(), 1);
904 assert!(!listed[0].orphaned);
905 assert_eq!(listed[0].record.process.pid, child.id());
906 assert!(listed[0].processes >= 1);
907
908 signal_group(child.id(), libc::SIGKILL);
909 child.wait().unwrap();
910 guard.finish().await;
911 assert!(!path.exists());
912 assert!(registry.list(true).is_empty());
913 }
914
915 #[tokio::test]
916 async fn kill_stops_a_local_run_and_marks_it_killed() {
917 let home = tempfile::tempdir().unwrap();
918 let registry = registry(home.path());
919 let pending = registry.begin("claude", "session", home.path(), None);
920 let environment = pending.environment.clone();
921 let mut child = spawn_tagged("trap '' TERM; sleep 30", &environment);
922 let guard = registry.register(pending, child.id()).unwrap();
923 registry.kill(guard.handle()).await.unwrap();
924 assert!(guard.was_killed());
925 assert!(child.wait().unwrap().code().is_none(), "killed by a signal");
926 assert!(registry.kill("claude-nosuch").await.is_err());
927 guard.finish().await;
928 }
929
930 #[cfg(target_os = "linux")]
931 #[tokio::test]
932 async fn reconcile_reaps_an_orphan_and_its_detached_descendants() {
933 let home = tempfile::tempdir().unwrap();
934 let owner = registry(home.path());
935 let pending = owner.begin("codex", "session", home.path(), None);
936 let environment = pending.environment.clone();
937 let mut child = spawn_tagged("setsid sleep 60 & exec sleep 60", &environment);
939 let guard = owner.register(pending, child.id()).unwrap();
940 let handle = guard.handle().to_owned();
941 assert!(wait_for(|| tagged_processes(&handle).len() >= 2).await);
942 let path = owner.record_dir().join(format!("{handle}.json"));
943 let mut record = read_record(&path).unwrap();
945 let mut gone = std::process::Command::new("true").spawn().unwrap();
946 let gone_pid = gone.id();
947 gone.wait().unwrap();
948 record.owner = ProcessIdentity {
949 pid: gone_pid,
950 start_time: 1,
951 };
952 write_record(owner.record_dir(), &record).unwrap();
953 std::mem::forget(guard);
954
955 let daemon = registry(home.path());
957 assert!(daemon.list(false).is_empty());
958 let orphans = daemon.list(true);
959 assert_eq!(orphans.len(), 1);
960 assert!(orphans[0].orphaned);
961 assert!(orphans[0].processes >= 2);
962 let report = daemon.reconcile().await;
963 assert_eq!(report.reaped, vec![handle.clone()]);
964 assert_eq!(daemon.reaped_total(), 1);
965 assert!(child.wait().unwrap().code().is_none());
966 assert!(wait_for(|| tagged_processes(&handle).is_empty()).await);
967 assert!(!path.exists());
968 assert_eq!(daemon.reconcile().await, ReconcileReport::default());
969 }
970
971 #[tokio::test]
972 async fn an_abandoned_run_is_cleaned_up_when_its_guard_drops() {
973 let home = tempfile::tempdir().unwrap();
974 let registry = registry(home.path());
975 let pending = registry.begin("pi", "session", home.path(), None);
976 let environment = pending.environment.clone();
977 let mut child = spawn_tagged("sleep 30", &environment);
978 let guard = registry.register(pending, child.id()).unwrap();
979 let path = registry
980 .record_dir()
981 .join(format!("{}.json", guard.handle()));
982 drop(guard);
983 assert!(child.wait().unwrap().code().is_none());
984 assert!(!path.exists());
985 }
986
987 #[test]
988 fn records_for_another_instance_or_under_the_wrong_name_are_ignored() {
989 let home = tempfile::tempdir().unwrap();
990 let registry = DelegationRegistry::new(home.path());
991 let dir = registry.record_dir().to_owned();
992 let record = DelegationRecord {
993 handle: "codex-abcdef".into(),
994 agent: "codex".into(),
995 instance: "other".into(),
996 session: "s".into(),
997 owner: ProcessIdentity {
998 pid: 1,
999 start_time: 1,
1000 },
1001 process: ProcessIdentity {
1002 pid: 1,
1003 start_time: 1,
1004 },
1005 pgid: 1,
1006 cwd: "/".into(),
1007 started_unix: 0,
1008 depth: 1,
1009 conversation: None,
1010 turn: None,
1011 };
1012 write_record(&dir, &record).unwrap();
1013 std::fs::copy(
1014 dir.join("codex-abcdef.json"),
1015 dir.join("codex-renamed.json"),
1016 )
1017 .unwrap();
1018 assert!(registry.list(true).is_empty());
1019 }
1020}