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.
34pub(crate) const 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    /// Conversation markers left by SCV processes that no longer run.
119    pub stale_markers: usize,
120}
121
122#[derive(Debug, Default)]
123struct Inner {
124    active: HashMap<String, Arc<AtomicBool>>,
125    reaped: u64,
126}
127
128/// The delegations one SCV process started, backed by the instance's records.
129#[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
139/// A delegation about to start: its handle and the environment tagging it.
140pub(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 of the delegated process.
148    depth: u32,
149}
150
151impl DelegationRegistry {
152    /// The registry for the SCV instance rooted at `instance_home`.
153    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    /// This process's own delegation depth.
172    pub fn depth(&self) -> u32 {
173        self.depth
174    }
175
176    pub fn record_dir(&self) -> &Path {
177        &self.record_dir
178    }
179
180    /// Short identifier of the SCV instance, shared by all its processes.
181    pub fn instance(&self) -> &str {
182        &self.instance
183    }
184
185    /// Delegations this process stopped as orphans since it started.
186    pub fn reaped_total(&self) -> u64 {
187        self.inner.lock().expect("registry lock").reaped
188    }
189
190    /// Where live conversations leave markers for `scv agents gc`.
191    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    /// Start recording a delegation whose owner is at `owner_depth`: the
210    /// process's own depth, or more when its client is itself delegated.
211    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    /// Record a spawned delegation. The returned guard removes the record and
244    /// stops leftovers when the run ends, even if the run is abandoned.
245    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    /// Delegations of this instance that are still running. With
296    /// `include_orphans`, also records whose owner died and await cleanup.
297    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    /// Stop one delegation of this instance, whichever process owns it.
325    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    /// Stop and remove every orphaned delegation of this instance.
350    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    /// Whether the process that owns `record` still runs it. A record this
369    /// process owns counts only while its run is active here.
370    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
404/// Keeps a delegation recorded while it runs.
405pub(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    /// Record that the run moved on to `turn` of its conversation, for a
420    /// live child that serves every turn. Bookkeeping only: a failure to
421    /// rewrite the record never fails the turn.
422    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    /// Whether `scv agents kill` stopped this run.
431    pub(crate) fn was_killed(&self) -> bool {
432        self.killed.load(Ordering::Acquire)
433    }
434
435    /// The run ended: stop anything still tagged with it, then forget it.
436    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        // The run was abandoned mid-flight: kill its group now and sweep
449        // tagged leftovers in the background.
450        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
463/// Stop a delegation's process group and tagged processes: TERM, then KILL
464/// after a short grace. Returns whether anything was still running.
465async fn stop_delegation(record: &DelegationRecord) -> bool {
466    let mut stopped = false;
467    // The group ID is the leader's PID, which the kernel does not reuse while
468    // the group has members. A live leader with a different start time means
469    // the PID was reused, so the group is not ours.
470    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
488/// TERM, then KILL, every process tagged with `handle`. Returns whether any was found.
489async 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
507/// Processes whose `SCV_PARENT` chain names `handle`.
508fn 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    // Never address group 0 or 1 (this process's own group, or init's).
536    if let Ok(pgid) = i32::try_from(pgid)
537        && pgid > 1
538    {
539        unsafe {
540            libc::kill(-pgid, signal);
541        }
542    }
543}
544
545/// Whether the process group still has a running member (zombies excluded on Linux).
546pub(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
573/// Atomically write `value` as `dir/name` with mode 0600, creating `dir` and
574/// keeping it and its parent (`run/`) private.
575pub(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    // Only a record named after its own handle is trusted.
607    (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
615/// Make this process the reaper of orphaned descendants (Linux), so processes
616/// a delegated agent leaves behind stay in SCV's process tree.
617pub 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
630/// Note a child this process spawned and will wait for itself.
631pub(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
645/// Collect exited orphans reparented to this subreaper. Children SCV spawned
646/// itself are left to their own waiters.
647pub 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
684/// Processes of interest at one moment: group membership and tags.
685struct 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        // `ps -E` appends each process's environment to its command line.
732        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        // The command name is parenthesized and may contain spaces or ')'.
802        let rest = &text[text.rfind(')')? + 2..];
803        let fields: Vec<&str> = rest.split_whitespace().collect();
804        // After the name: state(3) ppid(4) pgrp(5) ... starttime(22).
805        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    /// `SCV_PARENT` from a process's environment, when readable.
826    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    /// Spawn `sh -c script` in its own process group with `environment`.
861    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        // The agent starts a detached descendant in a new session, outside its group.
1011        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        // Rewrite the record as if a process that has since died owned it.
1017        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        // Another SCV process of the same instance reconciles.
1029        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}