sprawling 0.0.12

A local multi-agent harness for persistent agent teams: long-running project work, agent communication, and experiments in coordination and social simulation.
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright (c) 2026 2youg1 and the sprawling contributors

//! This process's own counters, read through its own handle rather than
//! the whole process table, so one reading costs about a microsecond
//! (`crates/sprawling/spec/Monitor.lean` §8-96).

use std::time::Duration;

use cpu_time::ProcessTime;

/// The five figures of this process one sample carries.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct OwnReading {
    pub(crate) cpu_permille: u64,
    pub(crate) private_bytes: u64,
    pub(crate) working_set_bytes: u64,
    pub(crate) read_bytes: u64,
    pub(crate) written_bytes: u64,
}

/// The previous reading's CPU time, which the next reading's CPU share
/// is measured against, and the highest private bytes noted since the
/// previous reading.
pub(crate) struct OwnProcess {
    previous: Option<Duration>,
    peak_private: u64,
}

impl OwnProcess {
    pub(crate) fn new() -> Self {
        Self {
            previous: None,
            peak_private: 0,
        }
    }

    /// Reads private bytes alone, between two readings, so the next
    /// reading reports the highest of them (`crates/sprawling/spec/Monitor.lean` §8-96).
    pub(crate) fn note_private(&mut self) {
        let now = private_bytes(memory_stats::memory_stats().as_ref());
        self.peak_private = self.peak_private.max(now);
    }

    /// Reads this process once; `elapsed` is the wall time since the
    /// previous reading. The first reading has nothing to compare with,
    /// so its CPU reads 0; a figure the platform refuses reads 0. Private
    /// bytes are the highest of this reading and those noted since the
    /// previous one.
    pub(crate) fn read(&mut self, elapsed: Duration) -> OwnReading {
        let cpu = ProcessTime::try_now().ok().map(|time| time.as_duration());
        let cpu_permille = match (cpu, self.previous) {
            (Some(spent), Some(spent_before)) => {
                permille(spent.saturating_sub(spent_before), elapsed)
            }
            (Some(_) | None, None) | (None, Some(_)) => 0,
        };
        self.previous = cpu;
        let memory = memory_stats::memory_stats();
        let storage = storage_bytes();
        OwnReading {
            cpu_permille,
            private_bytes: private_bytes(memory.as_ref())
                .max(std::mem::take(&mut self.peak_private)),
            working_set_bytes: memory.map_or(0, |stats| widen(stats.physical_mem)),
            read_bytes: storage.read,
            written_bytes: storage.written,
        }
    }
}

/// Windows reads `PagefileUsage`, the committed private bytes; macOS
/// reads the virtual size, which overstates them, until a safe interface
/// reaches `phys_footprint` (`crates/sprawling/spec/Serving/Memory.lean` D43).
#[cfg(not(target_os = "linux"))]
fn private_bytes(memory: Option<&memory_stats::MemoryStats>) -> u64 {
    memory.map_or(0, |stats| widen(stats.virtual_mem))
}

/// Linux sums the private lines of `/proc/self/smaps_rollup`, and reads
/// `RssAnon` from `/proc/self/status` where the kernel has no rollup;
/// both are plain files std reads safely (Memory.lean D43).
#[cfg(target_os = "linux")]
fn private_bytes(_memory: Option<&memory_stats::MemoryStats>) -> u64 {
    let read = |path: &str| std::fs::read_to_string(path).ok();
    read("/proc/self/smaps_rollup")
        .and_then(|rollup| kib_sum(&rollup, &["Private_Clean:", "Private_Dirty:"]))
        .or_else(|| read("/proc/self/status").and_then(|status| kib_sum(&status, &["RssAnon:"])))
        .unwrap_or(0)
}

/// The sum, in bytes, of the `kB` lines named by `fields`; `None` when
/// the text carries none of them.
#[cfg(any(target_os = "linux", test))]
fn kib_sum(text: &str, fields: &[&str]) -> Option<u64> {
    text.lines()
        .filter_map(|line| {
            fields
                .iter()
                .find_map(|field| line.strip_prefix(field))
                .and_then(|value| value.trim().strip_suffix("kB"))
                .and_then(|kib| kib.trim().parse::<u64>().ok())
        })
        .fold(None, |sum: Option<u64>, kib| {
            Some(sum.unwrap_or(0).saturating_add(kib.saturating_mul(1024)))
        })
}

/// The bytes this process has read from and written to storage.
#[derive(Default)]
struct StorageBytes {
    read: u64,
    written: u64,
}

/// Linux keeps them in `/proc/self/io`, a plain file std reads safely;
/// a line the kernel does not offer reads 0.
#[cfg(target_os = "linux")]
fn storage_bytes() -> StorageBytes {
    let Ok(io) = std::fs::read_to_string("/proc/self/io") else {
        return StorageBytes::default();
    };
    let field = |name: &str| {
        io.lines()
            .find_map(|line| line.strip_prefix(name))
            .and_then(|value| value.trim().parse().ok())
            .unwrap_or(0)
    };
    StorageBytes {
        read: field("read_bytes:"),
        written: field("write_bytes:"),
    }
}

/// Elsewhere the counter is reachable only through `unsafe`, which this
/// workspace forbids, so both read 0 (`crates/sprawling/spec/Monitor.lean` §8-96, decision 1).
#[cfg(not(target_os = "linux"))]
fn storage_bytes() -> StorageBytes {
    StorageBytes::default()
}

/// CPU time spent over wall time elapsed, spread over every core, as
/// permille of the whole machine, clamped to `0..=1000`.
fn permille(spent: Duration, elapsed: Duration) -> u64 {
    let cores = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
    let capacity = elapsed
        .as_nanos()
        .saturating_mul(u128::try_from(cores).unwrap_or(u128::MAX));
    spent
        .as_nanos()
        .saturating_mul(1000)
        .checked_div(capacity)
        .map_or(0, |share| u64::try_from(share.min(1000)).unwrap_or(1000))
}

fn widen(bytes: usize) -> u64 {
    u64::try_from(bytes).unwrap_or(u64::MAX)
}

#[cfg(test)]
#[path = "own_process/tests.rs"]
mod tests;