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