Skip to main content

gate4agent_runtime_native/
shell_efficiency.rs

1//! Folds `gate4agent_shell_native::ShellEfficiencyFacts` -- plain facts a
2//! `NativeEffectShell` accumulates inside `collect_terminal_frames` and
3//! `reclassify_foreground` -- into the same "distribution, not an average"
4//! contract `tick_profile` uses for `NativeRuntime::tick`'s own phases.
5//!
6//! See `ShellEfficiencyFacts`'s own doc comment for why the split is: that
7//! crate reports plain facts, this crate keeps statistics. It is the
8//! dependency direction (`gate4agent-shell-native` does not, and must not,
9//! depend on `gate4agent-runtime-native`) that makes this crate, not that
10//! one, the only place a fact can become a percentile -- do not "fix" that
11//! by moving `RingStats`/`Distribution` downstream or re-deriving a second
12//! copy of them in `gate4agent-shell-native`.
13//!
14//! `run_effect_worker`'s worker-loop task calls both shell methods above
15//! once per iteration and immediately drains their facts (see
16//! `publish_shell_observations`), folding them into the
17//! `Arc<Mutex<ShellEfficiencyProfile>>` that every `NativeWorkerContext`
18//! shares with `NativeEffectDispatcher` -- one worker per `AgentInstanceId`,
19//! all folding into the same shared profile. `NativeRuntime::
20//! shell_efficiency_snapshot` is the only reader, called once per
21//! drive-loop iteration by `gate4agent-node`, same cadence as
22//! `NativeRuntime::tick_profile_snapshot`.
23use crate::tick_profile::{duration_micros, Distribution, RingStats, SAMPLE_WINDOW};
24use gate4agent_shell_native::ShellEfficiencyFacts;
25
26/// One snapshot of every shell-efficiency series -- what
27/// [`crate::NativeRuntime::shell_efficiency_snapshot`] returns, and what
28/// `GET /metrics` renders alongside `TickProfileSnapshot`.
29#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
30pub struct ShellEfficiencyProfileSnapshot {
31    /// Duration of real (sequence-gate-passed) `terminal_state()` calls --
32    /// backlog item 3's "what a changed-screen capture actually costs".
33    pub terminal_state_us: Distribution,
34    /// Lifetime count of those real captures.
35    pub terminal_state_captures_total: u64,
36    /// Lifetime count of sessions the cheap sequence gate skipped instead
37    /// of paying for a real capture.
38    pub terminal_state_skips_total: u64,
39    /// Byte size of every published `TerminalFrame` -- backlog item 4's
40    /// "what a frame costs on the wire".
41    pub terminal_frame_bytes: Distribution,
42    /// Lifetime count of frames published.
43    pub terminal_frames_published_total: u64,
44    /// Lifetime total bytes across every frame published.
45    pub terminal_frame_bytes_total: u64,
46    /// The three components of `reclassify_foreground`'s OS process-tree
47    /// probes -- see `gate4agent::pty::ForegroundProbeTiming` for what each
48    /// one means. Kept as three independent series rather than one combined
49    /// duration: a total mixing pure `spawn_blocking`-queue waiting with
50    /// the mutex-contention wait and the actual process-table walk's CPU
51    /// leads to opposite conclusions depending on which of the three
52    /// dominates, so collapsing them back into one number would throw away
53    /// exactly what this split exists to preserve.
54    pub foreground_probe_queued_us: Distribution,
55    pub foreground_probe_lock_wait_us: Distribution,
56    pub foreground_probe_walk_us: Distribution,
57    /// Lifetime count of probes, not components -- one probe increments
58    /// this by one regardless of how many of the three series above it
59    /// also fed.
60    pub foreground_probes_total: u64,
61}
62
63/// Owns the shell-efficiency rings and lifetime counters folded from every
64/// per-instance worker's drained `ShellEfficiencyFacts`. Lives behind an
65/// `Arc<Mutex<_>>` shared by `NativeEffectDispatcher` and every
66/// `NativeWorkerContext` it spawns, since multiple worker tasks fold into
67/// it concurrently -- locked only for one `fold`, never across an
68/// `.await`, same rule as `gate4agent-node`'s `DriveLoopProfiler`.
69#[derive(Clone, Debug, Default)]
70pub struct ShellEfficiencyProfile {
71    terminal_state_us: RingStats<SAMPLE_WINDOW>,
72    terminal_state_captures_total: u64,
73    terminal_state_skips_total: u64,
74    terminal_frame_bytes: RingStats<SAMPLE_WINDOW>,
75    terminal_frames_published_total: u64,
76    terminal_frame_bytes_total: u64,
77    foreground_probe_queued_us: RingStats<SAMPLE_WINDOW>,
78    foreground_probe_lock_wait_us: RingStats<SAMPLE_WINDOW>,
79    foreground_probe_walk_us: RingStats<SAMPLE_WINDOW>,
80    foreground_probes_total: u64,
81}
82
83impl ShellEfficiencyProfile {
84    /// Fold one drained `ShellEfficiencyFacts` in -- every raw duration/byte
85    /// sample becomes a `RingStats::push`, every counter adds with
86    /// saturating arithmetic so a lifetime `u64` total can never wrap.
87    pub fn fold(&mut self, facts: &ShellEfficiencyFacts) {
88        for &sample in facts.terminal_state_samples() {
89            self.terminal_state_us.push(duration_micros(sample));
90        }
91        self.terminal_state_captures_total = self
92            .terminal_state_captures_total
93            .saturating_add(facts.terminal_state_captures());
94        self.terminal_state_skips_total = self
95            .terminal_state_skips_total
96            .saturating_add(facts.terminal_state_skips());
97
98        for &bytes in facts.terminal_frame_byte_samples() {
99            self.terminal_frame_bytes.push(bytes.min(u64::from(u32::MAX)) as u32);
100        }
101        self.terminal_frames_published_total = self
102            .terminal_frames_published_total
103            .saturating_add(facts.terminal_frames_published());
104        self.terminal_frame_bytes_total = self
105            .terminal_frame_bytes_total
106            .saturating_add(facts.terminal_frame_bytes_total());
107
108        for &sample in facts.foreground_probe_queued_samples() {
109            self.foreground_probe_queued_us.push(duration_micros(sample));
110        }
111        for &sample in facts.foreground_probe_lock_wait_samples() {
112            self.foreground_probe_lock_wait_us.push(duration_micros(sample));
113        }
114        for &sample in facts.foreground_probe_walk_samples() {
115            self.foreground_probe_walk_us.push(duration_micros(sample));
116        }
117        self.foreground_probes_total = self
118            .foreground_probes_total
119            .saturating_add(facts.foreground_probes());
120    }
121
122    pub fn snapshot(&self) -> ShellEfficiencyProfileSnapshot {
123        ShellEfficiencyProfileSnapshot {
124            terminal_state_us: self.terminal_state_us.stats(),
125            terminal_state_captures_total: self.terminal_state_captures_total,
126            terminal_state_skips_total: self.terminal_state_skips_total,
127            terminal_frame_bytes: self.terminal_frame_bytes.stats(),
128            terminal_frames_published_total: self.terminal_frames_published_total,
129            terminal_frame_bytes_total: self.terminal_frame_bytes_total,
130            foreground_probe_queued_us: self.foreground_probe_queued_us.stats(),
131            foreground_probe_lock_wait_us: self.foreground_probe_lock_wait_us.stats(),
132            foreground_probe_walk_us: self.foreground_probe_walk_us.stats(),
133            foreground_probes_total: self.foreground_probes_total,
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use gate4agent_shell_native::ForegroundProbeTiming;
142    use std::time::Duration;
143
144    #[test]
145    fn folding_a_drained_fact_set_updates_distributions_and_lifetime_counters() {
146        let mut facts = ShellEfficiencyFacts::default();
147        facts.record_terminal_state_capture(Duration::from_micros(1_500));
148        facts.record_terminal_state_skip();
149        facts.record_terminal_frame_published(4_096);
150        facts.record_foreground_probe(ForegroundProbeTiming {
151            queued: Duration::from_micros(2_500),
152            lock_wait: Duration::from_micros(700),
153            walk: Duration::from_micros(300),
154        });
155        let drained = facts.take();
156
157        let mut profile = ShellEfficiencyProfile::default();
158        profile.fold(&drained);
159        let snapshot = profile.snapshot();
160
161        assert_eq!(snapshot.terminal_state_us.max, 1_500);
162        assert_eq!(snapshot.terminal_state_captures_total, 1);
163        assert_eq!(snapshot.terminal_state_skips_total, 1);
164        assert_eq!(snapshot.terminal_frame_bytes.max, 4_096);
165        assert_eq!(snapshot.terminal_frames_published_total, 1);
166        assert_eq!(snapshot.terminal_frame_bytes_total, 4_096);
167        // Distinct values per component -- a copy-paste that recorded the
168        // same duration into all three series would pass an assertion that
169        // only checked one of them.
170        assert_eq!(snapshot.foreground_probe_queued_us.max, 2_500);
171        assert_eq!(snapshot.foreground_probe_lock_wait_us.max, 700);
172        assert_eq!(snapshot.foreground_probe_walk_us.max, 300);
173        assert_eq!(snapshot.foreground_probes_total, 1);
174    }
175
176    #[test]
177    fn one_probe_increments_the_lifetime_count_by_one_not_by_the_component_count() {
178        let mut facts = ShellEfficiencyFacts::default();
179        facts.record_foreground_probe(ForegroundProbeTiming {
180            queued: Duration::from_micros(10),
181            lock_wait: Duration::from_micros(20),
182            walk: Duration::from_micros(30),
183        });
184        let drained = facts.take();
185
186        let mut profile = ShellEfficiencyProfile::default();
187        profile.fold(&drained);
188        let snapshot = profile.snapshot();
189
190        assert_eq!(snapshot.foreground_probes_total, 1);
191        assert_eq!(snapshot.foreground_probe_queued_us.count, 1);
192        assert_eq!(snapshot.foreground_probe_lock_wait_us.count, 1);
193        assert_eq!(snapshot.foreground_probe_walk_us.count, 1);
194    }
195
196    #[test]
197    fn folding_an_empty_fact_set_changes_nothing() {
198        let mut profile = ShellEfficiencyProfile::default();
199        profile.fold(&ShellEfficiencyFacts::default());
200        assert_eq!(profile.snapshot(), ShellEfficiencyProfileSnapshot::default());
201    }
202}