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