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