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