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