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 {
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 set_turn(&self, turn: u32) {
423 let dir = &self.registry.record_dir;
424 if let Some(mut record) = read_record(&dir.join(format!("{}.json", self.handle))) {
425 record.turn = Some(turn);
426 let _ = write_record(dir, &record);
427 }
428 }
429
430 pub(crate) fn was_killed(&self) -> bool {
432 self.killed.load(Ordering::Acquire)
433 }
434
435 pub(crate) async fn finish(mut self) {
437 self.finished = true;
438 stop_tagged(&self.handle).await;
439 self.registry.finish_local(&self.handle);
440 }
441}
442
443impl Drop for DelegationGuard {
444 fn drop(&mut self) {
445 if self.finished {
446 return;
447 }
448 signal_group(self.pgid, libc::SIGKILL);
451 self.registry.finish_local(&self.handle);
452 let handle = self.handle.clone();
453 if let Ok(runtime) = tokio::runtime::Handle::try_current() {
454 runtime.spawn(async move { stop_tagged(&handle).await });
455 } else {
456 for identity in tagged_processes(&handle) {
457 signal(identity.pid, libc::SIGKILL);
458 }
459 }
460 }
461}
462
463async fn stop_delegation(record: &DelegationRecord) -> bool {
466 let mut stopped = false;
467 let leader = ProcessIdentity::of(record.process.pid);
471 let group_is_ours = record.pgid == record.process.pid
472 && match leader {
473 Some(leader) => leader == record.process,
474 None => group_exists(record.pgid),
475 };
476 if group_is_ours && group_exists(record.pgid) {
477 stopped = true;
478 signal_group(record.pgid, libc::SIGTERM);
479 let deadline = tokio::time::Instant::now() + STOP_GRACE;
480 while group_exists(record.pgid) && tokio::time::Instant::now() < deadline {
481 tokio::time::sleep(Duration::from_millis(50)).await;
482 }
483 signal_group(record.pgid, libc::SIGKILL);
484 }
485 stopped | stop_tagged(&record.handle).await
486}
487
488async fn stop_tagged(handle: &str) -> bool {
490 let tagged = tagged_processes(handle);
491 if tagged.is_empty() {
492 return false;
493 }
494 for identity in &tagged {
495 signal(identity.pid, libc::SIGTERM);
496 }
497 let deadline = tokio::time::Instant::now() + STOP_GRACE;
498 while tagged.iter().any(ProcessIdentity::is_alive) && tokio::time::Instant::now() < deadline {
499 tokio::time::sleep(Duration::from_millis(50)).await;
500 }
501 for identity in tagged.iter().filter(|identity| identity.is_alive()) {
502 signal(identity.pid, libc::SIGKILL);
503 }
504 true
505}
506
507fn tagged_processes(handle: &str) -> Vec<ProcessIdentity> {
509 let own = std::process::id();
510 ProcessTable::snapshot()
511 .tagged
512 .into_iter()
513 .filter(|(identity, chain)| identity.pid != own && chain_names(chain, handle))
514 .map(|(identity, _)| identity)
515 .collect()
516}
517
518fn chain_names(chain: &str, handle: &str) -> bool {
519 chain
520 .split(';')
521 .any(|entry| entry.rsplit('/').next() == Some(handle))
522}
523
524fn signal(pid: u32, signal: i32) {
525 if let Ok(pid) = i32::try_from(pid)
526 && pid > 0
527 {
528 unsafe {
529 libc::kill(pid, signal);
530 }
531 }
532}
533
534pub(crate) fn signal_group(pgid: u32, signal: i32) {
535 if let Ok(pgid) = i32::try_from(pgid)
537 && pgid > 1
538 {
539 unsafe {
540 libc::kill(-pgid, signal);
541 }
542 }
543}
544
545pub(crate) fn group_exists(pgid: u32) -> bool {
547 let Ok(group) = i32::try_from(pgid) else {
548 return false;
549 };
550 if group <= 1 {
551 return false;
552 }
553 let result = unsafe { libc::kill(-group, 0) };
554 let signalable =
555 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
556 #[cfg(target_os = "linux")]
557 {
558 signalable
559 && linux::all_stats()
560 .iter()
561 .any(|info| info.pgid == pgid && info.state != 'Z')
562 }
563 #[cfg(not(target_os = "linux"))]
564 {
565 signalable
566 }
567}
568
569fn write_record(dir: &Path, record: &DelegationRecord) -> std::io::Result<()> {
570 write_private_json(dir, &format!("{}.json", record.handle), record)
571}
572
573pub(crate) fn write_private_json(
576 dir: &Path,
577 name: &str,
578 value: &impl Serialize,
579) -> std::io::Result<()> {
580 use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
581 std::fs::create_dir_all(dir)?;
582 if let Some(run) = dir.parent() {
583 std::fs::set_permissions(run, std::fs::Permissions::from_mode(0o700))?;
584 }
585 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
586 let bytes = serde_json::to_vec_pretty(value).map_err(std::io::Error::other)?;
587 let temporary = dir.join(format!(".{name}.tmp"));
588 let mut file = std::fs::OpenOptions::new()
589 .write(true)
590 .create(true)
591 .truncate(true)
592 .mode(0o600)
593 .open(&temporary)?;
594 file.write_all(&bytes)?;
595 file.sync_all()?;
596 drop(file);
597 std::fs::rename(&temporary, dir.join(name))
598}
599
600fn read_record(path: &Path) -> Option<DelegationRecord> {
601 let file = std::fs::File::open(path).ok()?;
602 let mut bytes = Vec::new();
603 std::io::Read::read_to_end(&mut std::io::Read::take(file, MAX_RECORD_BYTES), &mut bytes)
604 .ok()?;
605 let record: DelegationRecord = serde_json::from_slice(&bytes).ok()?;
606 (path.file_stem().and_then(|stem| stem.to_str()) == Some(record.handle.as_str()))
608 .then_some(record)
609}
610
611fn remove_record(dir: &Path, handle: &str) {
612 let _ = std::fs::remove_file(dir.join(format!("{handle}.json")));
613}
614
615pub fn become_child_subreaper() -> bool {
618 #[cfg(target_os = "linux")]
619 {
620 unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) == 0 }
621 }
622 #[cfg(not(target_os = "linux"))]
623 {
624 false
625 }
626}
627
628static SPAWNED: Mutex<Option<HashSet<u32>>> = Mutex::new(None);
629
630pub(crate) fn track_spawned(pid: u32) {
632 SPAWNED
633 .lock()
634 .expect("spawned lock")
635 .get_or_insert_with(HashSet::new)
636 .insert(pid);
637}
638
639pub(crate) fn untrack_spawned(pid: u32) {
640 if let Some(spawned) = SPAWNED.lock().expect("spawned lock").as_mut() {
641 spawned.remove(&pid);
642 }
643}
644
645pub fn reap_orphaned_zombies() -> usize {
648 #[cfg(target_os = "linux")]
649 {
650 let own = std::process::id();
651 let spawned = SPAWNED
652 .lock()
653 .expect("spawned lock")
654 .clone()
655 .unwrap_or_default();
656 let uptime = linux::uptime_ticks();
657 let mut reaped = 0;
658 for info in linux::all_stats() {
659 if info.ppid != own || info.state != 'Z' || spawned.contains(&info.pid) {
660 continue;
661 }
662 let old_enough = uptime.is_some_and(|now| {
663 now.saturating_sub(info.start_time)
664 >= ZOMBIE_MIN_AGE.as_secs() * linux::clock_ticks()
665 });
666 if !old_enough {
667 continue;
668 }
669 let mut status = 0;
670 if unsafe { libc::waitpid(info.pid as i32, &mut status, libc::WNOHANG) }
671 == info.pid as i32
672 {
673 reaped += 1;
674 }
675 }
676 reaped
677 }
678 #[cfg(not(target_os = "linux"))]
679 {
680 0
681 }
682}
683
684struct ProcessTable {
686 groups: Vec<(ProcessIdentity, u32)>,
687 tagged: Vec<(ProcessIdentity, String)>,
688}
689
690impl ProcessTable {
691 fn members(&self, record: &DelegationRecord) -> HashSet<u32> {
692 let mut members: HashSet<u32> = self
693 .groups
694 .iter()
695 .filter(|(_, pgid)| *pgid == record.pgid)
696 .map(|(identity, _)| identity.pid)
697 .collect();
698 members.extend(
699 self.tagged
700 .iter()
701 .filter(|(_, chain)| chain_names(chain, &record.handle))
702 .map(|(identity, _)| identity.pid),
703 );
704 members
705 }
706
707 #[cfg(target_os = "linux")]
708 fn snapshot() -> Self {
709 let mut groups = Vec::new();
710 let mut tagged = Vec::new();
711 for info in linux::all_stats() {
712 if info.state == 'Z' {
713 continue;
714 }
715 let identity = ProcessIdentity {
716 pid: info.pid,
717 start_time: info.start_time,
718 };
719 groups.push((identity, info.pgid));
720 if let Some(chain) = linux::parent_chain(info.pid) {
721 tagged.push((identity, chain));
722 }
723 }
724 Self { groups, tagged }
725 }
726
727 #[cfg(not(target_os = "linux"))]
728 fn snapshot() -> Self {
729 let mut groups = Vec::new();
730 let mut tagged = Vec::new();
731 let Ok(output) = std::process::Command::new("ps")
733 .args(["-E", "-ww", "-axo", "pid=,pgid=,command="])
734 .output()
735 else {
736 return Self { groups, tagged };
737 };
738 for line in String::from_utf8_lossy(&output.stdout).lines() {
739 let mut fields = line.split_whitespace();
740 let (Some(pid), Some(pgid)) = (
741 fields.next().and_then(|value| value.parse::<u32>().ok()),
742 fields.next().and_then(|value| value.parse::<u32>().ok()),
743 ) else {
744 continue;
745 };
746 let Some(identity) = ProcessIdentity::of(pid) else {
747 continue;
748 };
749 groups.push((identity, pgid));
750 if let Some(chain) = fields.find_map(|field| {
751 field
752 .strip_prefix(PARENT_VARIABLE)
753 .and_then(|rest| rest.strip_prefix('='))
754 }) {
755 tagged.push((identity, chain.to_owned()));
756 }
757 }
758 Self { groups, tagged }
759 }
760}
761
762#[cfg(target_os = "linux")]
763fn process_start_time(pid: u32) -> Option<u64> {
764 linux::stat(pid).map(|info| info.start_time)
765}
766
767#[cfg(target_os = "macos")]
768fn process_start_time(pid: u32) -> Option<u64> {
769 let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
770 let size = std::mem::size_of::<libc::proc_bsdinfo>() as i32;
771 let written = unsafe {
772 libc::proc_pidinfo(
773 pid as i32,
774 libc::PROC_PIDTBSDINFO,
775 0,
776 (&mut info as *mut libc::proc_bsdinfo).cast(),
777 size,
778 )
779 };
780 (written == size).then(|| info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec)
781}
782
783#[cfg(not(any(target_os = "linux", target_os = "macos")))]
784fn process_start_time(pid: u32) -> Option<u64> {
785 let alive = unsafe { libc::kill(pid as i32, 0) } == 0;
786 alive.then_some(0)
787}
788
789#[cfg(target_os = "linux")]
790mod linux {
791 pub(super) struct Stat {
792 pub pid: u32,
793 pub ppid: u32,
794 pub pgid: u32,
795 pub state: char,
796 pub start_time: u64,
797 }
798
799 pub(super) fn stat(pid: u32) -> Option<Stat> {
800 let text = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
801 let rest = &text[text.rfind(')')? + 2..];
803 let fields: Vec<&str> = rest.split_whitespace().collect();
804 Some(Stat {
806 pid,
807 state: fields.first()?.chars().next()?,
808 ppid: fields.get(1)?.parse().ok()?,
809 pgid: fields.get(2)?.parse().ok()?,
810 start_time: fields.get(19)?.parse().ok()?,
811 })
812 }
813
814 pub(super) fn all_stats() -> Vec<Stat> {
815 let Ok(entries) = std::fs::read_dir("/proc") else {
816 return Vec::new();
817 };
818 entries
819 .filter_map(Result::ok)
820 .filter_map(|entry| entry.file_name().to_str()?.parse::<u32>().ok())
821 .filter_map(stat)
822 .collect()
823 }
824
825 pub(super) fn parent_chain(pid: u32) -> Option<String> {
827 let environ = std::fs::read(format!("/proc/{pid}/environ")).ok()?;
828 let prefix = format!("{}=", super::PARENT_VARIABLE);
829 environ.split(|byte| *byte == 0).find_map(|entry| {
830 entry
831 .strip_prefix(prefix.as_bytes())
832 .map(|value| String::from_utf8_lossy(value).into_owned())
833 })
834 }
835
836 pub(super) fn clock_ticks() -> u64 {
837 let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
838 u64::try_from(ticks)
839 .ok()
840 .filter(|ticks| *ticks > 0)
841 .unwrap_or(100)
842 }
843
844 pub(super) fn uptime_ticks() -> Option<u64> {
845 let text = std::fs::read_to_string("/proc/uptime").ok()?;
846 let seconds: f64 = text.split_whitespace().next()?.parse().ok()?;
847 Some((seconds * clock_ticks() as f64) as u64)
848 }
849}
850
851#[cfg(test)]
852mod tests {
853 use super::*;
854 use std::os::unix::{fs::PermissionsExt as _, process::CommandExt as _};
855
856 fn registry(home: &Path) -> Arc<DelegationRegistry> {
857 Arc::new(DelegationRegistry::new(home))
858 }
859
860 fn spawn_tagged(script: &str, environment: &[(OsString, OsString)]) -> std::process::Child {
862 std::process::Command::new("sh")
863 .args(["-c", script])
864 .envs(environment.iter().map(|(key, value)| (key, value)))
865 .stdin(std::process::Stdio::null())
866 .stdout(std::process::Stdio::null())
867 .stderr(std::process::Stdio::null())
868 .process_group(0)
869 .spawn()
870 .unwrap()
871 }
872
873 async fn wait_for(mut condition: impl FnMut() -> bool) -> bool {
874 for _ in 0..200 {
875 if condition() {
876 return true;
877 }
878 tokio::time::sleep(Duration::from_millis(25)).await;
879 }
880 false
881 }
882
883 #[test]
884 fn chains_match_only_their_own_handle() {
885 assert!(chain_names("abcd/s1/codex-1a2b3c", "codex-1a2b3c"));
886 assert!(chain_names(
887 "x/s/claude-000000;abcd/s1/codex-1a2b3c",
888 "codex-1a2b3c"
889 ));
890 assert!(!chain_names("abcd/s1/codex-1a2b3c", "codex-1a2b3"));
891 assert!(!chain_names("abcd/s1/codex-1a2b3c", "1a2b3c"));
892 }
893
894 #[test]
895 fn a_declared_client_depth_raises_the_recorded_depth() {
896 let home = tempfile::tempdir().unwrap();
897 let registry = DelegationRegistry::new(home.path());
898 let pending = registry.begin_at(2, "codex", "session", home.path(), None);
899 assert_eq!(pending.depth, 3);
900 assert!(
901 pending
902 .environment
903 .iter()
904 .any(|(name, value)| { name == DEPTH_VARIABLE && value == "3" })
905 );
906 }
907
908 #[test]
909 fn nested_tags_extend_the_chain_and_depth() {
910 let home = tempfile::tempdir().unwrap();
911 let mut registry = DelegationRegistry::new(home.path());
912 registry.chain = Some("aaaa/s0/codex-111111".into());
913 registry.depth = 1;
914 let pending = registry.begin("claude", "s1", home.path(), Some(("claude-1", 2)));
915 let value = |name: &str| {
916 pending
917 .environment
918 .iter()
919 .find(|(key, _)| key == name)
920 .map(|(_, value)| value.to_str().unwrap().to_owned())
921 .unwrap()
922 };
923 assert_eq!(
924 value(PARENT_VARIABLE),
925 format!(
926 "aaaa/s0/codex-111111;{}/s1/{}",
927 registry.instance, pending.handle
928 )
929 );
930 assert_eq!(value(DEPTH_VARIABLE), "2");
931 assert!(pending.handle.starts_with("claude-"));
932 }
933
934 #[tokio::test]
935 async fn records_are_private_and_removed_when_the_run_finishes() {
936 let home = tempfile::tempdir().unwrap();
937 let registry = registry(home.path());
938 let pending = registry.begin("codex", "session", home.path(), None);
939 let environment = pending.environment.clone();
940 let mut child = spawn_tagged("sleep 30", &environment);
941 let guard = registry.register(pending, child.id()).unwrap();
942 let path = registry
943 .record_dir()
944 .join(format!("{}.json", guard.handle()));
945 let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
946 assert_eq!(mode(&path), 0o600);
947 assert_eq!(mode(registry.record_dir()), 0o700);
948 assert_eq!(mode(registry.record_dir().parent().unwrap()), 0o700);
949
950 let listed = registry.list(false);
951 assert_eq!(listed.len(), 1);
952 assert!(!listed[0].orphaned);
953 assert_eq!(listed[0].record.process.pid, child.id());
954 assert!(listed[0].processes >= 1);
955
956 signal_group(child.id(), libc::SIGKILL);
957 child.wait().unwrap();
958 guard.finish().await;
959 assert!(!path.exists());
960 assert!(registry.list(true).is_empty());
961 }
962
963 #[tokio::test]
964 async fn kill_stops_a_local_run_and_marks_it_killed() {
965 let home = tempfile::tempdir().unwrap();
966 let registry = registry(home.path());
967 let pending = registry.begin("claude", "session", home.path(), None);
968 let environment = pending.environment.clone();
969 let mut child = spawn_tagged("trap '' TERM; sleep 30", &environment);
970 let guard = registry.register(pending, child.id()).unwrap();
971 registry.kill(guard.handle()).await.unwrap();
972 assert!(guard.was_killed());
973 assert!(child.wait().unwrap().code().is_none(), "killed by a signal");
974 assert!(registry.kill("claude-nosuch").await.is_err());
975 guard.finish().await;
976 }
977
978 #[cfg(target_os = "linux")]
979 #[tokio::test]
980 async fn reconcile_removes_conversation_markers_of_exited_processes() {
981 let home = tempfile::tempdir().unwrap();
982 let daemon = registry(home.path());
983 let markers = daemon.conversation_dir();
984 let mut gone = std::process::Command::new("true").spawn().unwrap();
985 let gone_pid = gone.id();
986 gone.wait().unwrap();
987 let dead = ProcessIdentity {
988 pid: gone_pid,
989 start_time: 1,
990 };
991 let live = ProcessIdentity::current().unwrap();
992 for (id, owner) in [("dead-id", dead), ("live-id", live)] {
993 let marker = serde_json::json!({"owner": owner, "agent": "codex", "handle": "codex-1"});
994 write_private_json(&markers, &format!("{id}.json"), &marker).unwrap();
995 }
996 let report = daemon.reconcile().await;
997 assert_eq!(report.stale_markers, 1);
998 assert!(!markers.join("dead-id.json").exists());
999 assert!(markers.join("live-id.json").is_file());
1000 assert_eq!(daemon.reconcile().await, ReconcileReport::default());
1001 }
1002
1003 #[cfg(target_os = "linux")]
1004 #[tokio::test]
1005 async fn reconcile_reaps_an_orphan_and_its_detached_descendants() {
1006 let home = tempfile::tempdir().unwrap();
1007 let owner = registry(home.path());
1008 let pending = owner.begin("codex", "session", home.path(), None);
1009 let environment = pending.environment.clone();
1010 let mut child = spawn_tagged("setsid sleep 60 & exec sleep 60", &environment);
1012 let guard = owner.register(pending, child.id()).unwrap();
1013 let handle = guard.handle().to_owned();
1014 assert!(wait_for(|| tagged_processes(&handle).len() >= 2).await);
1015 let path = owner.record_dir().join(format!("{handle}.json"));
1016 let mut record = read_record(&path).unwrap();
1018 let mut gone = std::process::Command::new("true").spawn().unwrap();
1019 let gone_pid = gone.id();
1020 gone.wait().unwrap();
1021 record.owner = ProcessIdentity {
1022 pid: gone_pid,
1023 start_time: 1,
1024 };
1025 write_record(owner.record_dir(), &record).unwrap();
1026 std::mem::forget(guard);
1027
1028 let daemon = registry(home.path());
1030 assert!(daemon.list(false).is_empty());
1031 let orphans = daemon.list(true);
1032 assert_eq!(orphans.len(), 1);
1033 assert!(orphans[0].orphaned);
1034 assert!(orphans[0].processes >= 2);
1035 let report = daemon.reconcile().await;
1036 assert_eq!(report.reaped, vec![handle.clone()]);
1037 assert_eq!(daemon.reaped_total(), 1);
1038 assert!(child.wait().unwrap().code().is_none());
1039 assert!(wait_for(|| tagged_processes(&handle).is_empty()).await);
1040 assert!(!path.exists());
1041 assert_eq!(daemon.reconcile().await, ReconcileReport::default());
1042 }
1043
1044 #[tokio::test]
1045 async fn an_abandoned_run_is_cleaned_up_when_its_guard_drops() {
1046 let home = tempfile::tempdir().unwrap();
1047 let registry = registry(home.path());
1048 let pending = registry.begin("pi", "session", home.path(), None);
1049 let environment = pending.environment.clone();
1050 let mut child = spawn_tagged("sleep 30", &environment);
1051 let guard = registry.register(pending, child.id()).unwrap();
1052 let path = registry
1053 .record_dir()
1054 .join(format!("{}.json", guard.handle()));
1055 drop(guard);
1056 assert!(child.wait().unwrap().code().is_none());
1057 assert!(!path.exists());
1058 }
1059
1060 #[test]
1061 fn records_for_another_instance_or_under_the_wrong_name_are_ignored() {
1062 let home = tempfile::tempdir().unwrap();
1063 let registry = DelegationRegistry::new(home.path());
1064 let dir = registry.record_dir().to_owned();
1065 let record = DelegationRecord {
1066 handle: "codex-abcdef".into(),
1067 agent: "codex".into(),
1068 instance: "other".into(),
1069 session: "s".into(),
1070 owner: ProcessIdentity {
1071 pid: 1,
1072 start_time: 1,
1073 },
1074 process: ProcessIdentity {
1075 pid: 1,
1076 start_time: 1,
1077 },
1078 pgid: 1,
1079 cwd: "/".into(),
1080 started_unix: 0,
1081 depth: 1,
1082 conversation: None,
1083 turn: None,
1084 };
1085 write_record(&dir, &record).unwrap();
1086 std::fs::copy(
1087 dir.join("codex-abcdef.json"),
1088 dir.join("codex-renamed.json"),
1089 )
1090 .unwrap();
1091 assert!(registry.list(true).is_empty());
1092 }
1093}