Skip to main content

scv_tools/
delegation.rs

1//! What SCV started, so it can list, stop, and clean up delegated agents.
2//!
3//! Every delegated process is tagged through its environment
4//! (`SCV_PARENT=<instance>/<session>/<handle>`, chained through nested SCVs,
5//! and `SCV_DELEGATION_DEPTH`) and recorded in
6//! `$SCV_HOME/run/delegations/<handle>.json` while it runs. A record whose
7//! owning SCV process died is an orphan: the daemon's reconciliation kills its
8//! process group and anything still carrying its tag, then removes it.
9//!
10//! This is cooperative bookkeeping. Delegated agents run as the user, so one
11//! that deliberately clears its environment or leaves its process group can
12//! escape it; the tags and records exist to clean up accidental leaks.
13
14use 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
29/// Environment variable carrying the delegation chain.
30pub const PARENT_VARIABLE: &str = "SCV_PARENT";
31/// Environment variable carrying how deeply this process is delegated.
32pub const DEPTH_VARIABLE: &str = "SCV_DELEGATION_DEPTH";
33/// Grace between TERM and KILL when stopping delegated processes.
34const STOP_GRACE: Duration = Duration::from_secs(2);
35/// Largest record file read.
36const MAX_RECORD_BYTES: u64 = 64 * 1024;
37/// A zombie child younger than this may still be awaited by its spawner.
38const ZOMBIE_MIN_AGE: Duration = Duration::from_secs(10);
39
40/// Delegation depth of the current process: 0 unless an SCV started it.
41pub 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/// A process, identified by PID plus start time so a reused PID never matches.
49#[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    /// Whether this exact process still runs. An exited process that its
65    /// parent has not yet collected (a zombie) does not count.
66    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/// One delegated run, as recorded on disk.
80#[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    /// The agent process, which leads its own process group.
88    pub process: ProcessIdentity,
89    pub pgid: u32,
90    pub cwd: PathBuf,
91    pub started_unix: u64,
92    /// Depth of the delegated process (the owner's depth plus one).
93    pub depth: u32,
94    /// The conversation this run is a turn of, and which turn.
95    #[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/// A record plus what SCV currently observes about it.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct DelegationEntry {
104    pub record: DelegationRecord,
105    /// The owning SCV process is gone; reconciliation will clean it up.
106    pub orphaned: bool,
107    /// Live processes in its group plus tagged processes outside it.
108    pub processes: usize,
109}
110
111/// What a reconciliation pass did.
112#[derive(Debug, Clone, Default, PartialEq, Eq)]
113pub struct ReconcileReport {
114    /// Orphaned delegations whose processes were stopped.
115    pub reaped: Vec<String>,
116    /// Orphaned records whose processes had already exited.
117    pub removed: usize,
118}
119
120#[derive(Debug, Default)]
121struct Inner {
122    active: HashMap<String, Arc<AtomicBool>>,
123    reaped: u64,
124}
125
126/// The delegations one SCV process started, backed by the instance's records.
127#[derive(Debug)]
128pub struct DelegationRegistry {
129    record_dir: PathBuf,
130    instance: String,
131    owner: Option<ProcessIdentity>,
132    depth: u32,
133    chain: Option<String>,
134    inner: Mutex<Inner>,
135}
136
137/// A delegation about to start: its handle and the environment tagging it.
138pub(crate) struct PendingDelegation {
139    pub handle: String,
140    pub environment: Vec<(OsString, OsString)>,
141    agent: String,
142    session: String,
143    cwd: PathBuf,
144    conversation: Option<(String, u32)>,
145}
146
147impl DelegationRegistry {
148    /// The registry for the SCV instance rooted at `instance_home`.
149    pub fn new(instance_home: &Path) -> Self {
150        let digest = Sha256::digest(instance_home.as_os_str().as_encoded_bytes());
151        let instance = digest[..4]
152            .iter()
153            .map(|byte| format!("{byte:02x}"))
154            .collect();
155        Self {
156            record_dir: instance_home.join("run").join("delegations"),
157            instance,
158            owner: ProcessIdentity::current(),
159            depth: current_depth(),
160            chain: std::env::var(PARENT_VARIABLE)
161                .ok()
162                .filter(|value| !value.trim().is_empty()),
163            inner: Mutex::new(Inner::default()),
164        }
165    }
166
167    /// This process's own delegation depth.
168    pub fn depth(&self) -> u32 {
169        self.depth
170    }
171
172    pub fn record_dir(&self) -> &Path {
173        &self.record_dir
174    }
175
176    /// Short identifier of the SCV instance, shared by all its processes.
177    pub fn instance(&self) -> &str {
178        &self.instance
179    }
180
181    /// Delegations this process stopped as orphans since it started.
182    pub fn reaped_total(&self) -> u64 {
183        self.inner.lock().expect("registry lock").reaped
184    }
185
186    /// Where live conversations leave markers for `scv agents gc`.
187    pub fn conversation_dir(&self) -> PathBuf {
188        self.record_dir.parent().map_or_else(
189            || self.record_dir.join("conversations"),
190            |run| run.join("conversations"),
191        )
192    }
193
194    pub(crate) fn begin(
195        &self,
196        agent: &str,
197        session: &str,
198        cwd: &Path,
199        conversation: Option<(&str, u32)>,
200    ) -> PendingDelegation {
201        let suffix = uuid::Uuid::new_v4().simple().to_string();
202        let handle = format!("{agent}-{}", &suffix[..6]);
203        let entry = format!("{}/{session}/{handle}", self.instance);
204        let chain = match &self.chain {
205            Some(chain) => format!("{chain};{entry}"),
206            None => entry,
207        };
208        PendingDelegation {
209            environment: vec![
210                (PARENT_VARIABLE.into(), chain.into()),
211                (DEPTH_VARIABLE.into(), (self.depth + 1).to_string().into()),
212            ],
213            handle,
214            agent: agent.to_owned(),
215            session: session.to_owned(),
216            cwd: cwd.to_owned(),
217            conversation: conversation.map(|(handle, turn)| (handle.to_owned(), turn)),
218        }
219    }
220
221    /// Record a spawned delegation. The returned guard removes the record and
222    /// stops leftovers when the run ends, even if the run is abandoned.
223    pub(crate) fn register(
224        self: &Arc<Self>,
225        pending: PendingDelegation,
226        pid: u32,
227    ) -> std::io::Result<DelegationGuard> {
228        let killed = Arc::new(AtomicBool::new(false));
229        let record = DelegationRecord {
230            handle: pending.handle.clone(),
231            agent: pending.agent,
232            instance: self.instance.clone(),
233            session: pending.session,
234            owner: self.owner.unwrap_or(ProcessIdentity {
235                pid: std::process::id(),
236                start_time: 0,
237            }),
238            process: ProcessIdentity::of(pid).unwrap_or(ProcessIdentity { pid, start_time: 0 }),
239            pgid: pid,
240            cwd: pending.cwd,
241            started_unix: SystemTime::now()
242                .duration_since(UNIX_EPOCH)
243                .map_or(0, |elapsed| elapsed.as_secs()),
244            depth: self.depth + 1,
245            conversation: pending
246                .conversation
247                .as_ref()
248                .map(|(handle, _)| handle.clone()),
249            turn: pending.conversation.as_ref().map(|(_, turn)| *turn),
250        };
251        self.inner
252            .lock()
253            .expect("registry lock")
254            .active
255            .insert(record.handle.clone(), Arc::clone(&killed));
256        if let Err(error) = write_record(&self.record_dir, &record) {
257            self.inner
258                .lock()
259                .expect("registry lock")
260                .active
261                .remove(&record.handle);
262            return Err(error);
263        }
264        Ok(DelegationGuard {
265            registry: Arc::clone(self),
266            handle: record.handle,
267            pgid: pid,
268            killed,
269            finished: false,
270        })
271    }
272
273    /// Delegations of this instance that are still running. With
274    /// `include_orphans`, also records whose owner died and await cleanup.
275    pub fn list(&self, include_orphans: bool) -> Vec<DelegationEntry> {
276        let table = ProcessTable::snapshot();
277        let mut entries: Vec<_> = self
278            .records()
279            .into_iter()
280            .filter_map(|record| {
281                let orphaned = !self.owner_alive(&record);
282                if orphaned && !include_orphans {
283                    return None;
284                }
285                let processes = table.members(&record).len();
286                Some(DelegationEntry {
287                    record,
288                    orphaned,
289                    processes,
290                })
291            })
292            .collect();
293        entries.sort_by(|a, b| {
294            a.record
295                .started_unix
296                .cmp(&b.record.started_unix)
297                .then_with(|| a.record.handle.cmp(&b.record.handle))
298        });
299        entries
300    }
301
302    /// Stop one delegation of this instance, whichever process owns it.
303    pub async fn kill(&self, handle: &str) -> Result<(), String> {
304        let record = self
305            .records()
306            .into_iter()
307            .find(|record| record.handle == handle)
308            .ok_or_else(|| format!("no running delegation {handle:?}"))?;
309        let local = self
310            .inner
311            .lock()
312            .expect("registry lock")
313            .active
314            .get(handle)
315            .cloned();
316        if let Some(killed) = &local {
317            killed.store(true, Ordering::Release);
318        }
319        stop_delegation(&record).await;
320        if local.is_none() && !self.owner_alive(&record) {
321            remove_record(&self.record_dir, handle);
322            self.inner.lock().expect("registry lock").reaped += 1;
323        }
324        Ok(())
325    }
326
327    /// Stop and remove every orphaned delegation of this instance.
328    pub async fn reconcile(&self) -> ReconcileReport {
329        let mut report = ReconcileReport::default();
330        for record in self.records() {
331            if self.owner_alive(&record) {
332                continue;
333            }
334            if stop_delegation(&record).await {
335                report.reaped.push(record.handle.clone());
336            } else {
337                report.removed += 1;
338            }
339            remove_record(&self.record_dir, &record.handle);
340        }
341        self.inner.lock().expect("registry lock").reaped += report.reaped.len() as u64;
342        report
343    }
344
345    /// Whether the process that owns `record` still runs it. A record this
346    /// process owns counts only while its run is active here.
347    fn owner_alive(&self, record: &DelegationRecord) -> bool {
348        if Some(record.owner) == self.owner {
349            return self
350                .inner
351                .lock()
352                .expect("registry lock")
353                .active
354                .contains_key(&record.handle);
355        }
356        record.owner.is_alive()
357    }
358
359    fn records(&self) -> Vec<DelegationRecord> {
360        let Ok(entries) = std::fs::read_dir(&self.record_dir) else {
361            return Vec::new();
362        };
363        entries
364            .filter_map(Result::ok)
365            .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
366            .filter_map(|entry| read_record(&entry.path()))
367            .filter(|record| record.instance == self.instance)
368            .collect()
369    }
370
371    fn finish_local(&self, handle: &str) {
372        self.inner
373            .lock()
374            .expect("registry lock")
375            .active
376            .remove(handle);
377        remove_record(&self.record_dir, handle);
378    }
379}
380
381/// Keeps a delegation recorded while it runs.
382pub(crate) struct DelegationGuard {
383    registry: Arc<DelegationRegistry>,
384    handle: String,
385    pgid: u32,
386    killed: Arc<AtomicBool>,
387    finished: bool,
388}
389
390impl DelegationGuard {
391    #[cfg(test)]
392    pub(crate) fn handle(&self) -> &str {
393        &self.handle
394    }
395
396    /// Whether `scv agents kill` stopped this run.
397    pub(crate) fn was_killed(&self) -> bool {
398        self.killed.load(Ordering::Acquire)
399    }
400
401    /// The run ended: stop anything still tagged with it, then forget it.
402    pub(crate) async fn finish(mut self) {
403        self.finished = true;
404        stop_tagged(&self.handle).await;
405        self.registry.finish_local(&self.handle);
406    }
407}
408
409impl Drop for DelegationGuard {
410    fn drop(&mut self) {
411        if self.finished {
412            return;
413        }
414        // The run was abandoned mid-flight: kill its group now and sweep
415        // tagged leftovers in the background.
416        signal_group(self.pgid, libc::SIGKILL);
417        self.registry.finish_local(&self.handle);
418        let handle = self.handle.clone();
419        if let Ok(runtime) = tokio::runtime::Handle::try_current() {
420            runtime.spawn(async move { stop_tagged(&handle).await });
421        } else {
422            for identity in tagged_processes(&handle) {
423                signal(identity.pid, libc::SIGKILL);
424            }
425        }
426    }
427}
428
429/// Stop a delegation's process group and tagged processes: TERM, then KILL
430/// after a short grace. Returns whether anything was still running.
431async fn stop_delegation(record: &DelegationRecord) -> bool {
432    let mut stopped = false;
433    // The group ID is the leader's PID, which the kernel does not reuse while
434    // the group has members. A live leader with a different start time means
435    // the PID was reused, so the group is not ours.
436    let leader = ProcessIdentity::of(record.process.pid);
437    let group_is_ours = record.pgid == record.process.pid
438        && match leader {
439            Some(leader) => leader == record.process,
440            None => group_exists(record.pgid),
441        };
442    if group_is_ours && group_exists(record.pgid) {
443        stopped = true;
444        signal_group(record.pgid, libc::SIGTERM);
445        let deadline = tokio::time::Instant::now() + STOP_GRACE;
446        while group_exists(record.pgid) && tokio::time::Instant::now() < deadline {
447            tokio::time::sleep(Duration::from_millis(50)).await;
448        }
449        signal_group(record.pgid, libc::SIGKILL);
450    }
451    stopped | stop_tagged(&record.handle).await
452}
453
454/// TERM, then KILL, every process tagged with `handle`. Returns whether any was found.
455async fn stop_tagged(handle: &str) -> bool {
456    let tagged = tagged_processes(handle);
457    if tagged.is_empty() {
458        return false;
459    }
460    for identity in &tagged {
461        signal(identity.pid, libc::SIGTERM);
462    }
463    let deadline = tokio::time::Instant::now() + STOP_GRACE;
464    while tagged.iter().any(ProcessIdentity::is_alive) && tokio::time::Instant::now() < deadline {
465        tokio::time::sleep(Duration::from_millis(50)).await;
466    }
467    for identity in tagged.iter().filter(|identity| identity.is_alive()) {
468        signal(identity.pid, libc::SIGKILL);
469    }
470    true
471}
472
473/// Processes whose `SCV_PARENT` chain names `handle`.
474fn tagged_processes(handle: &str) -> Vec<ProcessIdentity> {
475    let own = std::process::id();
476    ProcessTable::snapshot()
477        .tagged
478        .into_iter()
479        .filter(|(identity, chain)| identity.pid != own && chain_names(chain, handle))
480        .map(|(identity, _)| identity)
481        .collect()
482}
483
484fn chain_names(chain: &str, handle: &str) -> bool {
485    chain
486        .split(';')
487        .any(|entry| entry.rsplit('/').next() == Some(handle))
488}
489
490fn signal(pid: u32, signal: i32) {
491    if let Ok(pid) = i32::try_from(pid)
492        && pid > 0
493    {
494        unsafe {
495            libc::kill(pid, signal);
496        }
497    }
498}
499
500fn signal_group(pgid: u32, signal: i32) {
501    // Never address group 0 or 1 (this process's own group, or init's).
502    if let Ok(pgid) = i32::try_from(pgid)
503        && pgid > 1
504    {
505        unsafe {
506            libc::kill(-pgid, signal);
507        }
508    }
509}
510
511/// Whether the process group still has a running member (zombies excluded on Linux).
512fn group_exists(pgid: u32) -> bool {
513    let Ok(group) = i32::try_from(pgid) else {
514        return false;
515    };
516    if group <= 1 {
517        return false;
518    }
519    let result = unsafe { libc::kill(-group, 0) };
520    let signalable =
521        result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
522    #[cfg(target_os = "linux")]
523    {
524        signalable
525            && linux::all_stats()
526                .iter()
527                .any(|info| info.pgid == pgid && info.state != 'Z')
528    }
529    #[cfg(not(target_os = "linux"))]
530    {
531        signalable
532    }
533}
534
535fn write_record(dir: &Path, record: &DelegationRecord) -> std::io::Result<()> {
536    write_private_json(dir, &format!("{}.json", record.handle), record)
537}
538
539/// Atomically write `value` as `dir/name` with mode 0600, creating `dir` and
540/// keeping it and its parent (`run/`) private.
541pub(crate) fn write_private_json(
542    dir: &Path,
543    name: &str,
544    value: &impl Serialize,
545) -> std::io::Result<()> {
546    use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
547    std::fs::create_dir_all(dir)?;
548    if let Some(run) = dir.parent() {
549        std::fs::set_permissions(run, std::fs::Permissions::from_mode(0o700))?;
550    }
551    std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
552    let bytes = serde_json::to_vec_pretty(value).map_err(std::io::Error::other)?;
553    let temporary = dir.join(format!(".{name}.tmp"));
554    let mut file = std::fs::OpenOptions::new()
555        .write(true)
556        .create(true)
557        .truncate(true)
558        .mode(0o600)
559        .open(&temporary)?;
560    file.write_all(&bytes)?;
561    file.sync_all()?;
562    drop(file);
563    std::fs::rename(&temporary, dir.join(name))
564}
565
566fn read_record(path: &Path) -> Option<DelegationRecord> {
567    let file = std::fs::File::open(path).ok()?;
568    let mut bytes = Vec::new();
569    std::io::Read::read_to_end(&mut std::io::Read::take(file, MAX_RECORD_BYTES), &mut bytes)
570        .ok()?;
571    let record: DelegationRecord = serde_json::from_slice(&bytes).ok()?;
572    // Only a record named after its own handle is trusted.
573    (path.file_stem().and_then(|stem| stem.to_str()) == Some(record.handle.as_str()))
574        .then_some(record)
575}
576
577fn remove_record(dir: &Path, handle: &str) {
578    let _ = std::fs::remove_file(dir.join(format!("{handle}.json")));
579}
580
581/// Make this process the reaper of orphaned descendants (Linux), so processes
582/// a delegated agent leaves behind stay in SCV's process tree.
583pub fn become_child_subreaper() -> bool {
584    #[cfg(target_os = "linux")]
585    {
586        unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) == 0 }
587    }
588    #[cfg(not(target_os = "linux"))]
589    {
590        false
591    }
592}
593
594static SPAWNED: Mutex<Option<HashSet<u32>>> = Mutex::new(None);
595
596/// Note a child this process spawned and will wait for itself.
597pub(crate) fn track_spawned(pid: u32) {
598    SPAWNED
599        .lock()
600        .expect("spawned lock")
601        .get_or_insert_with(HashSet::new)
602        .insert(pid);
603}
604
605pub(crate) fn untrack_spawned(pid: u32) {
606    if let Some(spawned) = SPAWNED.lock().expect("spawned lock").as_mut() {
607        spawned.remove(&pid);
608    }
609}
610
611/// Collect exited orphans reparented to this subreaper. Children SCV spawned
612/// itself are left to their own waiters.
613pub fn reap_orphaned_zombies() -> usize {
614    #[cfg(target_os = "linux")]
615    {
616        let own = std::process::id();
617        let spawned = SPAWNED
618            .lock()
619            .expect("spawned lock")
620            .clone()
621            .unwrap_or_default();
622        let uptime = linux::uptime_ticks();
623        let mut reaped = 0;
624        for info in linux::all_stats() {
625            if info.ppid != own || info.state != 'Z' || spawned.contains(&info.pid) {
626                continue;
627            }
628            let old_enough = uptime.is_some_and(|now| {
629                now.saturating_sub(info.start_time)
630                    >= ZOMBIE_MIN_AGE.as_secs() * linux::clock_ticks()
631            });
632            if !old_enough {
633                continue;
634            }
635            let mut status = 0;
636            if unsafe { libc::waitpid(info.pid as i32, &mut status, libc::WNOHANG) }
637                == info.pid as i32
638            {
639                reaped += 1;
640            }
641        }
642        reaped
643    }
644    #[cfg(not(target_os = "linux"))]
645    {
646        0
647    }
648}
649
650/// Processes of interest at one moment: group membership and tags.
651struct ProcessTable {
652    groups: Vec<(ProcessIdentity, u32)>,
653    tagged: Vec<(ProcessIdentity, String)>,
654}
655
656impl ProcessTable {
657    fn members(&self, record: &DelegationRecord) -> HashSet<u32> {
658        let mut members: HashSet<u32> = self
659            .groups
660            .iter()
661            .filter(|(_, pgid)| *pgid == record.pgid)
662            .map(|(identity, _)| identity.pid)
663            .collect();
664        members.extend(
665            self.tagged
666                .iter()
667                .filter(|(_, chain)| chain_names(chain, &record.handle))
668                .map(|(identity, _)| identity.pid),
669        );
670        members
671    }
672
673    #[cfg(target_os = "linux")]
674    fn snapshot() -> Self {
675        let mut groups = Vec::new();
676        let mut tagged = Vec::new();
677        for info in linux::all_stats() {
678            if info.state == 'Z' {
679                continue;
680            }
681            let identity = ProcessIdentity {
682                pid: info.pid,
683                start_time: info.start_time,
684            };
685            groups.push((identity, info.pgid));
686            if let Some(chain) = linux::parent_chain(info.pid) {
687                tagged.push((identity, chain));
688            }
689        }
690        Self { groups, tagged }
691    }
692
693    #[cfg(not(target_os = "linux"))]
694    fn snapshot() -> Self {
695        let mut groups = Vec::new();
696        let mut tagged = Vec::new();
697        // `ps -E` appends each process's environment to its command line.
698        let Ok(output) = std::process::Command::new("ps")
699            .args(["-E", "-ww", "-axo", "pid=,pgid=,command="])
700            .output()
701        else {
702            return Self { groups, tagged };
703        };
704        for line in String::from_utf8_lossy(&output.stdout).lines() {
705            let mut fields = line.split_whitespace();
706            let (Some(pid), Some(pgid)) = (
707                fields.next().and_then(|value| value.parse::<u32>().ok()),
708                fields.next().and_then(|value| value.parse::<u32>().ok()),
709            ) else {
710                continue;
711            };
712            let Some(identity) = ProcessIdentity::of(pid) else {
713                continue;
714            };
715            groups.push((identity, pgid));
716            if let Some(chain) = fields.find_map(|field| {
717                field
718                    .strip_prefix(PARENT_VARIABLE)
719                    .and_then(|rest| rest.strip_prefix('='))
720            }) {
721                tagged.push((identity, chain.to_owned()));
722            }
723        }
724        Self { groups, tagged }
725    }
726}
727
728#[cfg(target_os = "linux")]
729fn process_start_time(pid: u32) -> Option<u64> {
730    linux::stat(pid).map(|info| info.start_time)
731}
732
733#[cfg(target_os = "macos")]
734fn process_start_time(pid: u32) -> Option<u64> {
735    let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
736    let size = std::mem::size_of::<libc::proc_bsdinfo>() as i32;
737    let written = unsafe {
738        libc::proc_pidinfo(
739            pid as i32,
740            libc::PROC_PIDTBSDINFO,
741            0,
742            (&mut info as *mut libc::proc_bsdinfo).cast(),
743            size,
744        )
745    };
746    (written == size).then(|| info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec)
747}
748
749#[cfg(not(any(target_os = "linux", target_os = "macos")))]
750fn process_start_time(pid: u32) -> Option<u64> {
751    let alive = unsafe { libc::kill(pid as i32, 0) } == 0;
752    alive.then_some(0)
753}
754
755#[cfg(target_os = "linux")]
756mod linux {
757    pub(super) struct Stat {
758        pub pid: u32,
759        pub ppid: u32,
760        pub pgid: u32,
761        pub state: char,
762        pub start_time: u64,
763    }
764
765    pub(super) fn stat(pid: u32) -> Option<Stat> {
766        let text = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
767        // The command name is parenthesized and may contain spaces or ')'.
768        let rest = &text[text.rfind(')')? + 2..];
769        let fields: Vec<&str> = rest.split_whitespace().collect();
770        // After the name: state(3) ppid(4) pgrp(5) ... starttime(22).
771        Some(Stat {
772            pid,
773            state: fields.first()?.chars().next()?,
774            ppid: fields.get(1)?.parse().ok()?,
775            pgid: fields.get(2)?.parse().ok()?,
776            start_time: fields.get(19)?.parse().ok()?,
777        })
778    }
779
780    pub(super) fn all_stats() -> Vec<Stat> {
781        let Ok(entries) = std::fs::read_dir("/proc") else {
782            return Vec::new();
783        };
784        entries
785            .filter_map(Result::ok)
786            .filter_map(|entry| entry.file_name().to_str()?.parse::<u32>().ok())
787            .filter_map(stat)
788            .collect()
789    }
790
791    /// `SCV_PARENT` from a process's environment, when readable.
792    pub(super) fn parent_chain(pid: u32) -> Option<String> {
793        let environ = std::fs::read(format!("/proc/{pid}/environ")).ok()?;
794        let prefix = format!("{}=", super::PARENT_VARIABLE);
795        environ.split(|byte| *byte == 0).find_map(|entry| {
796            entry
797                .strip_prefix(prefix.as_bytes())
798                .map(|value| String::from_utf8_lossy(value).into_owned())
799        })
800    }
801
802    pub(super) fn clock_ticks() -> u64 {
803        let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
804        u64::try_from(ticks)
805            .ok()
806            .filter(|ticks| *ticks > 0)
807            .unwrap_or(100)
808    }
809
810    pub(super) fn uptime_ticks() -> Option<u64> {
811        let text = std::fs::read_to_string("/proc/uptime").ok()?;
812        let seconds: f64 = text.split_whitespace().next()?.parse().ok()?;
813        Some((seconds * clock_ticks() as f64) as u64)
814    }
815}
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820    use std::os::unix::{fs::PermissionsExt as _, process::CommandExt as _};
821
822    fn registry(home: &Path) -> Arc<DelegationRegistry> {
823        Arc::new(DelegationRegistry::new(home))
824    }
825
826    /// Spawn `sh -c script` in its own process group with `environment`.
827    fn spawn_tagged(script: &str, environment: &[(OsString, OsString)]) -> std::process::Child {
828        std::process::Command::new("sh")
829            .args(["-c", script])
830            .envs(environment.iter().map(|(key, value)| (key, value)))
831            .stdin(std::process::Stdio::null())
832            .stdout(std::process::Stdio::null())
833            .stderr(std::process::Stdio::null())
834            .process_group(0)
835            .spawn()
836            .unwrap()
837    }
838
839    async fn wait_for(mut condition: impl FnMut() -> bool) -> bool {
840        for _ in 0..200 {
841            if condition() {
842                return true;
843            }
844            tokio::time::sleep(Duration::from_millis(25)).await;
845        }
846        false
847    }
848
849    #[test]
850    fn chains_match_only_their_own_handle() {
851        assert!(chain_names("abcd/s1/codex-1a2b3c", "codex-1a2b3c"));
852        assert!(chain_names(
853            "x/s/claude-000000;abcd/s1/codex-1a2b3c",
854            "codex-1a2b3c"
855        ));
856        assert!(!chain_names("abcd/s1/codex-1a2b3c", "codex-1a2b3"));
857        assert!(!chain_names("abcd/s1/codex-1a2b3c", "1a2b3c"));
858    }
859
860    #[test]
861    fn nested_tags_extend_the_chain_and_depth() {
862        let home = tempfile::tempdir().unwrap();
863        let mut registry = DelegationRegistry::new(home.path());
864        registry.chain = Some("aaaa/s0/codex-111111".into());
865        registry.depth = 1;
866        let pending = registry.begin("claude", "s1", home.path(), Some(("claude-1", 2)));
867        let value = |name: &str| {
868            pending
869                .environment
870                .iter()
871                .find(|(key, _)| key == name)
872                .map(|(_, value)| value.to_str().unwrap().to_owned())
873                .unwrap()
874        };
875        assert_eq!(
876            value(PARENT_VARIABLE),
877            format!(
878                "aaaa/s0/codex-111111;{}/s1/{}",
879                registry.instance, pending.handle
880            )
881        );
882        assert_eq!(value(DEPTH_VARIABLE), "2");
883        assert!(pending.handle.starts_with("claude-"));
884    }
885
886    #[tokio::test]
887    async fn records_are_private_and_removed_when_the_run_finishes() {
888        let home = tempfile::tempdir().unwrap();
889        let registry = registry(home.path());
890        let pending = registry.begin("codex", "session", home.path(), None);
891        let environment = pending.environment.clone();
892        let mut child = spawn_tagged("sleep 30", &environment);
893        let guard = registry.register(pending, child.id()).unwrap();
894        let path = registry
895            .record_dir()
896            .join(format!("{}.json", guard.handle()));
897        let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
898        assert_eq!(mode(&path), 0o600);
899        assert_eq!(mode(registry.record_dir()), 0o700);
900        assert_eq!(mode(registry.record_dir().parent().unwrap()), 0o700);
901
902        let listed = registry.list(false);
903        assert_eq!(listed.len(), 1);
904        assert!(!listed[0].orphaned);
905        assert_eq!(listed[0].record.process.pid, child.id());
906        assert!(listed[0].processes >= 1);
907
908        signal_group(child.id(), libc::SIGKILL);
909        child.wait().unwrap();
910        guard.finish().await;
911        assert!(!path.exists());
912        assert!(registry.list(true).is_empty());
913    }
914
915    #[tokio::test]
916    async fn kill_stops_a_local_run_and_marks_it_killed() {
917        let home = tempfile::tempdir().unwrap();
918        let registry = registry(home.path());
919        let pending = registry.begin("claude", "session", home.path(), None);
920        let environment = pending.environment.clone();
921        let mut child = spawn_tagged("trap '' TERM; sleep 30", &environment);
922        let guard = registry.register(pending, child.id()).unwrap();
923        registry.kill(guard.handle()).await.unwrap();
924        assert!(guard.was_killed());
925        assert!(child.wait().unwrap().code().is_none(), "killed by a signal");
926        assert!(registry.kill("claude-nosuch").await.is_err());
927        guard.finish().await;
928    }
929
930    #[cfg(target_os = "linux")]
931    #[tokio::test]
932    async fn reconcile_reaps_an_orphan_and_its_detached_descendants() {
933        let home = tempfile::tempdir().unwrap();
934        let owner = registry(home.path());
935        let pending = owner.begin("codex", "session", home.path(), None);
936        let environment = pending.environment.clone();
937        // The agent starts a detached descendant in a new session, outside its group.
938        let mut child = spawn_tagged("setsid sleep 60 & exec sleep 60", &environment);
939        let guard = owner.register(pending, child.id()).unwrap();
940        let handle = guard.handle().to_owned();
941        assert!(wait_for(|| tagged_processes(&handle).len() >= 2).await);
942        let path = owner.record_dir().join(format!("{handle}.json"));
943        // Rewrite the record as if a process that has since died owned it.
944        let mut record = read_record(&path).unwrap();
945        let mut gone = std::process::Command::new("true").spawn().unwrap();
946        let gone_pid = gone.id();
947        gone.wait().unwrap();
948        record.owner = ProcessIdentity {
949            pid: gone_pid,
950            start_time: 1,
951        };
952        write_record(owner.record_dir(), &record).unwrap();
953        std::mem::forget(guard);
954
955        // Another SCV process of the same instance reconciles.
956        let daemon = registry(home.path());
957        assert!(daemon.list(false).is_empty());
958        let orphans = daemon.list(true);
959        assert_eq!(orphans.len(), 1);
960        assert!(orphans[0].orphaned);
961        assert!(orphans[0].processes >= 2);
962        let report = daemon.reconcile().await;
963        assert_eq!(report.reaped, vec![handle.clone()]);
964        assert_eq!(daemon.reaped_total(), 1);
965        assert!(child.wait().unwrap().code().is_none());
966        assert!(wait_for(|| tagged_processes(&handle).is_empty()).await);
967        assert!(!path.exists());
968        assert_eq!(daemon.reconcile().await, ReconcileReport::default());
969    }
970
971    #[tokio::test]
972    async fn an_abandoned_run_is_cleaned_up_when_its_guard_drops() {
973        let home = tempfile::tempdir().unwrap();
974        let registry = registry(home.path());
975        let pending = registry.begin("pi", "session", home.path(), None);
976        let environment = pending.environment.clone();
977        let mut child = spawn_tagged("sleep 30", &environment);
978        let guard = registry.register(pending, child.id()).unwrap();
979        let path = registry
980            .record_dir()
981            .join(format!("{}.json", guard.handle()));
982        drop(guard);
983        assert!(child.wait().unwrap().code().is_none());
984        assert!(!path.exists());
985    }
986
987    #[test]
988    fn records_for_another_instance_or_under_the_wrong_name_are_ignored() {
989        let home = tempfile::tempdir().unwrap();
990        let registry = DelegationRegistry::new(home.path());
991        let dir = registry.record_dir().to_owned();
992        let record = DelegationRecord {
993            handle: "codex-abcdef".into(),
994            agent: "codex".into(),
995            instance: "other".into(),
996            session: "s".into(),
997            owner: ProcessIdentity {
998                pid: 1,
999                start_time: 1,
1000            },
1001            process: ProcessIdentity {
1002                pid: 1,
1003                start_time: 1,
1004            },
1005            pgid: 1,
1006            cwd: "/".into(),
1007            started_unix: 0,
1008            depth: 1,
1009            conversation: None,
1010            turn: None,
1011        };
1012        write_record(&dir, &record).unwrap();
1013        std::fs::copy(
1014            dir.join("codex-abcdef.json"),
1015            dir.join("codex-renamed.json"),
1016        )
1017        .unwrap();
1018        assert!(registry.list(true).is_empty());
1019    }
1020}