Skip to main content

fno_agents/
lib.rs

1//! `fno-agents` substrate crate (Phase 6, ab-a09e1eaf).
2//!
3//! This crate is the Rust substrate for PTY-managed agents (codex / gemini /
4//! future OpenCode). It is split per the design's Locked Decisions:
5//!
6//! - shared types (this module): [`ShortId`], [`AgentStatus`], [`ParsedEvent`]
7//!   (LD9, sealed enum), [`MonotonicTimestamp`] (count-during-sleep clock).
8//! - [`pty`]: PTY spawn + bounded-ring output drainer (LD31).
9//! - [`write_queue`]: bounded-backpressure stdin queue + [`write_queue::WriteMsg`].
10//! - [`supervisor`]: [`supervisor::RestartPolicy`] state machine + hard ceiling (LD36).
11//! - [`readiness`]: [`readiness::ReadinessDetector`] trait + `UnknownReadinessSignal`
12//!   (Open Question #9: no generic byte-count fallback; per-CLI signal mandatory).
13//!
14//! ## Scope of Wave 1 (this PR)
15//!
16//! Wave 0's smoke prototype (`cli/scripts/smoke/pty-survival/`) refuted the
17//! "direct daemon-owned PTY survives daemon restart" assertion: a child on a
18//! PTY whose master the supervisor owns is SIGHUP'd and dies the instant the
19//! master closes. The locked outcome (Outcome B) was a per-agent worker process
20//! that owned the master and outlived the daemon. That daemon-owned PTY hosting
21//! was retired at G4: the mux is now the agent-PTY substrate, and this crate
22//! keeps the registry, inside-leg reports, and the claude stream-json adopt lane.
23//!
24//! Deliberately deferred (documented seams, not gaps):
25//! - `alacritty_terminal` grid wiring + per-CLI [`readiness::ReadinessDetector`]
26//!   impls -> Wave 2, alongside the smoke captures that define the grid patterns
27//!   (the trait operates over [`readiness::ScreenView`] so Wave 2 only adds impls).
28//! - `tokio` runtime integration -> Wave 3 (the daemon is its only consumer; the
29//!   substrate stays runtime-agnostic and is driven from `spawn_blocking`).
30//!
31//! ## Scope of Wave 2 (this PR)
32//!
33//! Wave 2 fills the seams Wave 1 left:
34//! - [`provider`]: [`provider::Provider`] + [`provider::ProviderWithPty`] traits
35//!   (LD8) and the three impls ([`provider::ClaudeProvider`] shellout,
36//!   [`provider::CodexProvider`] / [`provider::GeminiProvider`] PTY-managed).
37//! - [`envelope`]: [`envelope::Envelope`] structural anti-injection wrapper (LD15).
38//! - [`screen`]: the terminal-grid construction behind [`readiness::ScreenView`]
39//!   (the per-CLI [`readiness::ReadinessDetector`] impls now live in
40//!   [`readiness`]).
41
42pub mod active_backlog;
43pub mod agents_config;
44pub mod agy_ask;
45pub mod claims;
46pub mod claude_adopt;
47pub mod claude_ask;
48pub mod claude_attach;
49pub mod claude_drive;
50pub mod claude_roster;
51pub mod client;
52pub mod client_verbs;
53pub mod codex_ask;
54pub mod codex_inject;
55mod completion_output;
56pub mod daemon;
57pub mod delivery_completion;
58pub mod digest;
59pub mod drift;
60pub mod envelope;
61pub mod events;
62pub mod finalize;
63pub mod gc;
64pub mod gemini_ask;
65mod identity;
66pub mod kill_criteria;
67pub mod logs;
68pub mod logs_client;
69pub mod loop_dispatch;
70pub mod loop_runtime;
71pub mod loop_target;
72pub mod loopcheck;
73pub mod mail_inject;
74pub mod manifest;
75pub mod needs;
76pub mod nudge;
77pub mod opencode_ask;
78pub mod osc;
79pub mod paths;
80pub mod protocol;
81pub mod provider;
82pub mod readiness;
83pub mod scrape;
84pub mod screen;
85pub mod spawn_gate;
86pub mod state;
87pub mod stream_worker;
88pub mod subprocess_ask;
89pub mod subscribe;
90pub mod supervisor;
91pub mod terminal_stop;
92pub mod verify_evidence;
93pub mod version;
94pub mod wait;
95pub mod write_queue;
96
97use serde::{Deserialize, Serialize};
98use std::time::Duration;
99
100/// A short, opaque agent identifier (e.g. `wkA`). Stored in the registry and
101/// used to name per-agent state directories. Validation is intentionally light
102/// at this layer; dispatch-layer validation (US1 invariant) owns argv rules.
103#[derive(Debug, thiserror::Error, PartialEq, Eq)]
104pub enum ShortIdError {
105    #[error("short id must be non-empty")]
106    Empty,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
110pub struct ShortId(pub(crate) String);
111
112impl ShortId {
113    /// Construct a short id. The field is crate-private and this is the only
114    /// constructor, so a zero-length registry key (which would collapse
115    /// per-agent state directory paths) cannot be built at any call site.
116    /// Charset rules beyond non-empty remain the dispatch layer's
117    /// responsibility (US1 argv validation).
118    pub fn new(s: impl Into<String>) -> Result<Self, ShortIdError> {
119        let s = s.into();
120        if s.is_empty() {
121            return Err(ShortIdError::Empty);
122        }
123        Ok(ShortId(s))
124    }
125
126    pub fn as_str(&self) -> &str {
127        &self.0
128    }
129}
130
131impl std::fmt::Display for ShortId {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.write_str(&self.0)
134    }
135}
136
137/// Agent lifecycle status. `state.status` is canonical; `registry.status` is a
138/// denormalized projection of it (LD10). Serialized snake_case for the JSON
139/// state files and the cross-language schemas.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "snake_case")]
142pub enum AgentStatus {
143    /// PTY spawned, not yet confirmed ready for input.
144    Spawning,
145    /// Confirmed ready for input (readiness_detector reported ready).
146    Ready,
147    /// Alive and waiting (equivalent to `Ready` for drive-eligibility, LD28).
148    Idle,
149    /// Mid-reply / actively processing.
150    Busy,
151    /// Live shorthand used by the registry projection.
152    Live,
153    /// Restart policy is backing off before re-spawn.
154    Restarting,
155    /// Reachability probe failed; needs reconcile or rm.
156    Orphaned,
157    /// Per-agent task panicked (provider parse panic, etc.); restart policy applies.
158    Failed,
159    /// Child exited; registry entry retained until rm.
160    Exited,
161    /// Restart hard ceiling hit (LD36); will not restart again.
162    PermanentDead,
163}
164
165impl AgentStatus {
166    /// Drive is accepted only for these statuses (LD28). `Idle`/`Live` are
167    /// equivalent to `Ready` for drive purposes.
168    pub fn is_drive_eligible(&self) -> bool {
169        matches!(
170            self,
171            AgentStatus::Ready | AgentStatus::Idle | AgentStatus::Busy | AgentStatus::Live
172        )
173    }
174}
175
176/// Sealed event vocabulary every provider parses INTO (LD9). Variant additions
177/// are a one-line crate-wide change; no per-provider enums. `#[serde(tag="kind")]`
178/// matches the wire shape in the design's Architecture section.
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180#[serde(tag = "kind", rename_all = "snake_case")]
181pub enum ParsedEvent {
182    SessionCreated {
183        session_id: String,
184    },
185    OutputChunk {
186        text: String,
187    },
188    ReplyComplete {
189        text: String,
190        duration_ms: u64,
191    },
192    ToolUse {
193        name: String,
194        args: Option<serde_json::Value>,
195    },
196    ProviderError {
197        message: String,
198    },
199    /// A line the provider's parser did not recognize. Tee'd to timeline.jsonl
200    /// as `unknown_stream_event` rather than dropped, so a provider version bump
201    /// degrades gracefully (Silent-Failure-Hunter finding).
202    Unknown {
203        raw: String,
204    },
205}
206
207/// A monotonic timestamp that **counts during system sleep**, used for
208/// drive-window heartbeat math (LD17 + Domain Pitfall: macOS/Linux suspend
209/// divergence).
210///
211/// Rust's `std::time::Instant` is inconsistent across platforms for the
212/// sleep case: on macOS it uses `mach_continuous_time` (counts sleep), on
213/// Linux it uses `CLOCK_MONOTONIC` (does NOT count sleep). A laptop-sleep
214/// during a drive window must EXPIRE the window, so we standardize on the
215/// count-during-sleep semantic on both:
216///
217/// - Linux: `clock_gettime(CLOCK_BOOTTIME)`.
218/// - macOS: `mach_continuous_time()` converted to ns via `mach_timebase_info`.
219///
220/// Stored as nanoseconds since an unspecified epoch; only differences are
221/// meaningful. Wall-clock `ts` for human audit lives in events.jsonl, tracked
222/// independently (LD17).
223#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
224pub struct MonotonicTimestamp(u64);
225
226impl MonotonicTimestamp {
227    /// Read the current count-during-sleep monotonic clock.
228    pub fn now() -> Self {
229        MonotonicTimestamp(raw_monotonic_nanos())
230    }
231
232    /// Nanoseconds elapsed since `earlier`. Saturates at 0 if `earlier` is in
233    /// the future (clock readings are monotonic, so this only guards against a
234    /// caller passing a later timestamp as `earlier`).
235    pub fn duration_since(&self, earlier: MonotonicTimestamp) -> Duration {
236        Duration::from_nanos(self.0.saturating_sub(earlier.0))
237    }
238
239    /// Convenience: elapsed since this timestamp until now.
240    pub fn elapsed(&self) -> Duration {
241        MonotonicTimestamp::now().duration_since(*self)
242    }
243
244    /// Raw nanoseconds, for persisting the heartbeat baseline to state.json.
245    pub fn as_nanos(&self) -> u64 {
246        self.0
247    }
248
249    /// Reconstruct from raw nanoseconds previously read via [`as_nanos`]. Used
250    /// by the daemon (Wave 3) to restore a persisted heartbeat baseline. Only
251    /// meaningful when paired with a `now()` from the same daemon incarnation's
252    /// clock (the value is epoch-relative to the running clock).
253    ///
254    /// [`as_nanos`]: MonotonicTimestamp::as_nanos
255    pub fn from_nanos(nanos: u64) -> Self {
256        MonotonicTimestamp(nanos)
257    }
258}
259
260#[cfg(target_os = "linux")]
261fn raw_monotonic_nanos() -> u64 {
262    // CLOCK_BOOTTIME includes time spent suspended (unlike CLOCK_MONOTONIC).
263    let mut ts = libc::timespec {
264        tv_sec: 0,
265        tv_nsec: 0,
266    };
267    // SAFETY: `ts` is a valid, owned timespec; CLOCK_BOOTTIME is a valid clock
268    // id on Linux >= 2.6.39.
269    let rc = unsafe { libc::clock_gettime(libc::CLOCK_BOOTTIME, &mut ts) };
270    if rc != 0 {
271        // clock_gettime on a standard clock id effectively never fails on a
272        // supported kernel, so treat it as a should-be-impossible fault and
273        // make it LOUD rather than silent. Returning 0 is NOT a universal
274        // fail-safe: if a *baseline* read failed, elapsed over-reports (window
275        // expires early - safe); if a *current* read fails, elapsed under-
276        // reports toward 0 (window could hang open - unsafe). We accept that
277        // residual risk only because the failure cannot occur in practice, and
278        // log so it never passes unnoticed.
279        tracing::error!("clock_gettime(CLOCK_BOOTTIME) failed; monotonic reading degraded to 0");
280        return 0;
281    }
282    (ts.tv_sec as u64)
283        .saturating_mul(1_000_000_000)
284        .saturating_add(ts.tv_nsec.max(0) as u64)
285}
286
287#[cfg(target_os = "macos")]
288fn raw_monotonic_nanos() -> u64 {
289    // mach_continuous_time() counts during sleep; convert mach ticks -> ns via
290    // the timebase ratio (1/1 on current Apple hardware, but we must not assume
291    // it). `libc` deprecated its mach timebase helpers and dropped
292    // mach_continuous_time entirely (it lives in the `mach2` crate now), so we
293    // declare the two libSystem symbols directly to avoid a macOS-only crate
294    // dependency. Both are part of libSystem, linked by default on macOS.
295    #[repr(C)]
296    struct MachTimebaseInfo {
297        numer: u32,
298        denom: u32,
299    }
300    extern "C" {
301        fn mach_continuous_time() -> u64;
302        fn mach_timebase_info(info: *mut MachTimebaseInfo) -> libc::c_int;
303    }
304    use std::sync::OnceLock;
305    static TIMEBASE: OnceLock<(u64, u64)> = OnceLock::new();
306    let (numer, denom) = *TIMEBASE.get_or_init(|| {
307        let mut info = MachTimebaseInfo { numer: 0, denom: 0 };
308        // SAFETY: `info` is a valid, owned, repr(C) struct matching the C ABI;
309        // mach_timebase_info fills it and returns a kern_return_t.
310        let rc = unsafe { mach_timebase_info(&mut info) };
311        if rc != 0 || info.denom == 0 {
312            (1, 1)
313        } else {
314            (info.numer as u64, info.denom as u64)
315        }
316    });
317    // SAFETY: no arguments; returns a monotonic tick count that counts sleep.
318    let ticks = unsafe { mach_continuous_time() };
319    // ns = ticks * numer / denom, computed in u128 to avoid overflow.
320    ((ticks as u128 * numer as u128) / denom as u128) as u64
321}
322
323#[cfg(not(any(target_os = "linux", target_os = "macos")))]
324fn raw_monotonic_nanos() -> u64 {
325    // Other POSIX targets are not shipped by Phase 6 (Windows is Phase 7+).
326    // Fall back to CLOCK_MONOTONIC so the crate still compiles for dev on
327    // such hosts; the suspend semantic is undefined there and not relied on.
328    let mut ts = libc::timespec {
329        tv_sec: 0,
330        tv_nsec: 0,
331    };
332    // SAFETY: valid owned timespec; CLOCK_MONOTONIC is POSIX-standard.
333    let rc = unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
334    if rc != 0 {
335        tracing::error!("clock_gettime(CLOCK_MONOTONIC) failed; monotonic reading degraded to 0");
336        return 0;
337    }
338    (ts.tv_sec as u64)
339        .saturating_mul(1_000_000_000)
340        .saturating_add(ts.tv_nsec.max(0) as u64)
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn short_id_rejects_empty() {
349        assert_eq!(ShortId::new(""), Err(ShortIdError::Empty));
350        let ok = ShortId::new("wkA").unwrap();
351        assert_eq!(ok.as_str(), "wkA");
352    }
353
354    #[test]
355    fn agent_status_serde_roundtrip_is_snake_case() {
356        let json = serde_json::to_string(&AgentStatus::PermanentDead).unwrap();
357        assert_eq!(json, "\"permanent_dead\"");
358        let back: AgentStatus = serde_json::from_str(&json).unwrap();
359        assert_eq!(back, AgentStatus::PermanentDead);
360    }
361
362    #[test]
363    fn drive_eligibility_matches_ld28() {
364        assert!(AgentStatus::Ready.is_drive_eligible());
365        assert!(AgentStatus::Idle.is_drive_eligible());
366        assert!(AgentStatus::Busy.is_drive_eligible());
367        assert!(!AgentStatus::Restarting.is_drive_eligible());
368        assert!(!AgentStatus::Exited.is_drive_eligible());
369        assert!(!AgentStatus::PermanentDead.is_drive_eligible());
370    }
371
372    #[test]
373    fn parsed_event_tagged_serde() {
374        let ev = ParsedEvent::ReplyComplete {
375            text: "hi".into(),
376            duration_ms: 42,
377        };
378        let json = serde_json::to_string(&ev).unwrap();
379        assert!(json.contains("\"kind\":\"reply_complete\""));
380        let back: ParsedEvent = serde_json::from_str(&json).unwrap();
381        assert_eq!(ev, back);
382    }
383
384    #[test]
385    fn parsed_event_unknown_preserves_raw() {
386        let ev = ParsedEvent::Unknown {
387            raw: "{\"new_event\":1}".into(),
388        };
389        let json = serde_json::to_string(&ev).unwrap();
390        let back: ParsedEvent = serde_json::from_str(&json).unwrap();
391        assert_eq!(ev, back);
392    }
393
394    #[test]
395    fn monotonic_clock_is_nondecreasing_and_measures_elapsed() {
396        let t0 = MonotonicTimestamp::now();
397        std::thread::sleep(Duration::from_millis(20));
398        let t1 = MonotonicTimestamp::now();
399        assert!(t1 >= t0, "monotonic clock went backwards");
400        let elapsed = t1.duration_since(t0);
401        assert!(
402            elapsed >= Duration::from_millis(15),
403            "elapsed too small: {elapsed:?}"
404        );
405        assert!(
406            elapsed < Duration::from_secs(5),
407            "elapsed implausibly large: {elapsed:?}"
408        );
409    }
410
411    #[test]
412    fn duration_since_future_saturates_to_zero() {
413        let t0 = MonotonicTimestamp::now();
414        std::thread::sleep(Duration::from_millis(5));
415        let t1 = MonotonicTimestamp::now();
416        // Passing the later ts as `earlier` must not panic or underflow.
417        assert_eq!(t0.duration_since(t1), Duration::ZERO);
418    }
419
420    // ── cv-114f75cc: production emit-kind completeness guard ──────────────
421    // KNOWN_EVENT_KINDS is hand-maintained and feeds both the Branch B `kind`
422    // schema enum and the cross-language parity gate, so a new `.emit("foo")`
423    // whose kind was never added to the constant would silently drift those
424    // surfaces. This test scans every production call site and fails on drift.
425
426    #[test]
427    fn every_production_emit_kind_is_registered() {
428        use std::collections::BTreeSet;
429
430        let known: BTreeSet<&str> = KNOWN_EVENT_KINDS.iter().copied().collect();
431        let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
432
433        let mut files = Vec::new();
434        collect_rs_files(&src_root, &mut files);
435        assert!(!files.is_empty(), "found no .rs files under {src_root:?}");
436
437        let mut unregistered: Vec<String> = Vec::new();
438        let mut production_kinds: BTreeSet<String> = BTreeSet::new();
439        let mut scanned_calls = 0usize;
440        for file in &files {
441            let text = std::fs::read_to_string(file).expect("read source file");
442            // Production code only: truncate at the first `#[cfg(test)]` marker.
443            // Tests live at the bottom by Rust convention, so test fixtures like
444            // `.emit("tick")` are excluded. (Verified: every production emit in
445            // this crate precedes its file's first `#[cfg(test)]`.)
446            let prod = match text.find("#[cfg(test)]") {
447                Some(i) => &text[..i],
448                None => &text[..],
449            };
450            let file_name = file.file_name().unwrap().to_string_lossy();
451            for (kind, line) in scan_emit_kinds(prod) {
452                scanned_calls += 1;
453                production_kinds.insert(kind.clone());
454                if !known.contains(kind.as_str()) {
455                    unregistered.push(format!(
456                        "{file_name}:{line}: .emit(\"{kind}\") not in KNOWN_EVENT_KINDS"
457                    ));
458                }
459            }
460        }
461
462        assert!(
463            scanned_calls > 0,
464            "scanner found zero emit call sites - the scan pattern likely broke"
465        );
466
467        // cv-2801ed8a: enforce the truncation assumption rather than just
468        // documenting it. The scan above trusts that every production emit
469        // precedes its file's first `#[cfg(test)]`. Verify it: scan BELOW each
470        // boundary too, and require every kind found there to be either also
471        // emitted in production (so the registration guard above already saw
472        // it) or a known test-only fixture. A production-looking kind that
473        // lives only below a boundary would otherwise escape the guard
474        // silently. `production_kinds` must be complete across ALL files before
475        // this check (a kind can be production in one file and test-only in
476        // another), so this is a second pass.
477        //
478        // `tick`/`heartbeat` are test fixture emits; `foo`/`x` are `.emit(...)`
479        // examples inside doc comments in the test module that the byte-level
480        // scanner picks up. (Escaped `.emit(\"...\")` in the scanner self-check
481        // string is NOT matched: the char after `(` is a backslash, not `"`.)
482        const TEST_ONLY_EMIT_KINDS: &[&str] = &["tick", "heartbeat", "foo", "x"];
483        let test_only: BTreeSet<&str> = TEST_ONLY_EMIT_KINDS.iter().copied().collect();
484
485        let mut below_only: Vec<String> = Vec::new();
486        for file in &files {
487            let text = std::fs::read_to_string(file).expect("read source file");
488            let boundary = match text.find("#[cfg(test)]") {
489                Some(i) => i,
490                None => continue,
491            };
492            // scan_emit_kinds reports lines relative to its input slice; add the
493            // newline count before the boundary so the message points at the
494            // real file line.
495            let base_line = text[..boundary].bytes().filter(|&c| c == b'\n').count();
496            let file_name = file.file_name().unwrap().to_string_lossy();
497            for (kind, line) in scan_emit_kinds(&text[boundary..]) {
498                if production_kinds.contains(&kind) || test_only.contains(kind.as_str()) {
499                    continue;
500                }
501                below_only.push(format!(
502                    "{file_name}:{}: .emit(\"{kind}\") appears only below #[cfg(test)] \
503                     (not emitted in production, not a known test-only fixture)",
504                    base_line + line
505                ));
506            }
507        }
508
509        assert!(
510            below_only.is_empty(),
511            "emit kinds found only below a #[cfg(test)] boundary - the truncation \
512             assumption (all production emits precede the test module) may be \
513             violated. If a kind below is a real production emit, register it in \
514             KNOWN_EVENT_KINDS and move it above the test module; if it is \
515             test-only, add it to TEST_ONLY_EMIT_KINDS:\n  {}",
516            below_only.join("\n  ")
517        );
518
519        // Self-check: the scanner extracts a single-line `.emit(` kind, a
520        // multi-line `.emit_fields(` kind, AND a whitespace-before-paren
521        // `.emit (` kind (valid Rust), so a genuine unregistered kind cannot
522        // slip past this guard silently. Also asserts the reported line number.
523        let synthetic = "x.emit(\"agent_spawned\", &p);\n  y.emit_fields(\n    \"definitely_not_a_real_kind\", m);\n z.emit (\"another_fake_kind\");";
524        let scanned = scan_emit_kinds(synthetic);
525        assert!(
526            scanned.iter().any(|(k, l)| k == "agent_spawned" && *l == 1),
527            "scanner missed a single-line emit kind (or wrong line)"
528        );
529        assert!(
530            scanned
531                .iter()
532                .any(|(k, _)| k == "definitely_not_a_real_kind"),
533            "scanner missed a multi-line emit_fields kind"
534        );
535        assert!(
536            scanned.iter().any(|(k, _)| k == "another_fake_kind"),
537            "scanner missed a `.emit (` call with whitespace before the paren"
538        );
539        assert!(
540            !known.contains("definitely_not_a_real_kind") && !known.contains("another_fake_kind"),
541            "the synthetic drift kinds must not be real registered kinds"
542        );
543        assert!(
544            unregistered.is_empty(),
545            "production emit kinds missing from KNOWN_EVENT_KINDS:\n  {}",
546            unregistered.join("\n  ")
547        );
548    }
549
550    fn collect_rs_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
551        let entries = match std::fs::read_dir(dir) {
552            Ok(e) => e,
553            Err(_) => return,
554        };
555        for entry in entries.flatten() {
556            let path = entry.path();
557            if path.is_dir() {
558                collect_rs_files(&path, out);
559            } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
560                out.push(path);
561            }
562        }
563    }
564
565    /// Extract `(kind, line)` for every `.emit` / `.emit_fields` call with a
566    /// string-literal kind. Whitespace tolerant on both sides: `.emit ("x")`
567    /// and a newline between `(` and the opening quote both parse (valid Rust).
568    /// A call whose first argument is not a string literal is skipped - the
569    /// kind is dynamic and not statically checkable. The line number (1-based)
570    /// is reported so a drift failure points straight at the offending call.
571    fn scan_emit_kinds(src: &str) -> Vec<(String, usize)> {
572        let bytes = src.as_bytes();
573        let mut kinds = Vec::new();
574        for needle in [".emit", ".emit_fields"] {
575            let nb = needle.as_bytes();
576            let mut from = 0usize;
577            while let Some(rel) = find_sub(&bytes[from..], nb) {
578                let pos = from + rel;
579                let mut j = pos + nb.len();
580                // `.emit` must not match inside `.emit_fields` (next char `_`).
581                if needle == ".emit" && j < bytes.len() && bytes[j] == b'_' {
582                    from = j;
583                    continue;
584                }
585                while j < bytes.len() && (bytes[j] as char).is_whitespace() {
586                    j += 1;
587                }
588                if j < bytes.len() && bytes[j] == b'(' {
589                    j += 1;
590                    while j < bytes.len() && (bytes[j] as char).is_whitespace() {
591                        j += 1;
592                    }
593                    if j < bytes.len() && bytes[j] == b'"' {
594                        let start = j + 1;
595                        let mut k = start;
596                        while k < bytes.len() && bytes[k] != b'"' {
597                            k += 1;
598                        }
599                        if k < bytes.len() {
600                            let kind = String::from_utf8_lossy(&bytes[start..k]).into_owned();
601                            let line = src[..pos].bytes().filter(|&c| c == b'\n').count() + 1;
602                            kinds.push((kind, line));
603                        }
604                    }
605                }
606                from = pos + nb.len();
607            }
608        }
609        kinds
610    }
611
612    fn find_sub(haystack: &[u8], needle: &[u8]) -> Option<usize> {
613        if needle.is_empty() || haystack.len() < needle.len() {
614            return None;
615        }
616        haystack.windows(needle.len()).position(|w| w == needle)
617    }
618}
619
620// ---------------------------------------------------------------------------
621// W7: Cross-language schema introspection
622// ---------------------------------------------------------------------------
623
624/// All real operator-facing event kinds emitted by the Rust supervisor.
625/// Excludes test-only kinds (tick, heartbeat).
626///
627/// This const is the authoritative list for `--emit-schema` output and must
628/// stay in sync with every `.emit(kind, ...)` / `.emit_fields(kind, ...)`
629/// call site in the crate. The parity check script compares this list against
630/// the Python side for global uniqueness.
631///
632/// **How to regenerate when adding a new event kind:**
633/// ```text
634/// grep -rn '\.emit\b\|\.emit_fields\b' crates/fno-agents/src/ \
635///   | grep -v '//' \
636///   | grep -oP '"[a-z_]+"' \
637///   | sort | uniq
638/// ```
639/// Then cross-check the output against this list. Test-only kinds (tick,
640/// heartbeat) and value fields (reason, backend, ...) will appear in the grep
641/// output; only include kinds that appear as the first string argument to an
642/// emit call in non-test production code.
643pub const KNOWN_EVENT_KINDS: &[&str] = &[
644    // Agent lifecycle (daemon-emitted)
645    "agent_spawned",
646    "agent_stopped",
647    "agent_exited",
648    "agent_removed",
649    "agent_inconsistent",
650    "agent_ask_done",
651    "agent_create_no_session",
652    "agent_orphan_reaped",
653    "agent_orphan_state_archived",
654    // Dead-row GC (daemon/reap-verb-emitted, x-b1aa): a terminal, past-grace,
655    // clean agent-view row was removed from the registry by the GC sweep or
656    // `fno agents reap`. Distinct from `agent_orphan_reaped` (which flips a
657    // live-but-unowned PID to exited); this REMOVES the row entirely.
658    "agent_row_reaped",
659    // Dead-row GC also reconstructs the loop's canonical failure event when a
660    // convention-named dispatch disappeared without a termination receipt.
661    "node_failed",
662    // Terminal-stop sweep (daemon-emitted, x-fcbf): a fire-and-forget
663    // `claude --bg` worker that finalize marked terminal was `claude stop`ped so
664    // its slot frees instead of parking at an idle prompt forever.
665    "bg_worker_terminal_stopped",
666    "agent_spawn_failed",
667    "agent_stop_error",
668    "agent_spawn_cwd_fallback",
669    // Claude stream-json adoption front door (daemon-emitted, ab-734fcd6c):
670    // advisory note that the single-writer claim substrate could not be consulted
671    // before spawning, so the adopt proceeded fail-open (the registry one-host
672    // re-check is the authoritative guard).
673    "agent_stream_claim_unavailable",
674    // Channel (daemon-emitted)
675    "channel_registered",
676    // Daemon lifecycle (daemon-emitted)
677    "daemon_started",
678    "daemon_exited",
679    "daemon_idle_pending_exit",
680    "daemon_shutting_down",
681    "daemon_state",
682    "daemon_recovery_error",
683    // Binary-version drift (daemon-emitted, plan ab-1891cdff): advisory note that
684    // the daemon could not fingerprint its own executable at startup, so every
685    // client drift check fails safe to Unknown.
686    "daemon_exe_fingerprint_unavailable",
687    // Drive (daemon-emitted)
688    "drive_attached",
689    "drive_detached",
690    "drive_crashed",
691    "drive_force_close_timeout",
692    "drive_keystroke_stepped",
693    "drive_refused_busy_elsewhere",
694    "drive_takeover_after_stale",
695    "drive_watch_input_rejected",
696    // Reconcile (daemon-emitted)
697    "reconcile_deferred",
698    "reconcile_done",
699    "reconcile_error",
700    // Startup reconcile sweep (daemon-emitted, plan ab-70faa65b Architecture B)
701    "startup_reconcile_done",
702    "startup_reconcile_failed",
703    // Deliver (daemon-emitted, Task 2.2 US4)
704    "agent_deliver_injected",
705    "agent_deliver_demoted",
706    "agent_deliver_status_write_failed",
707    // Active-backlog mission drain supervisor (daemon-emitted): the drain tick
708    // panicked and the supervisor is restarting it with backoff. The drain
709    // decision events (active_backlog_dispatched / _parked / _skip) are
710    // loop-stream events via Journal::append, NOT daemon emits, so they are
711    // exempt from this registry by design.
712    "active_backlog_task_crashed",
713    // A mission drain loop retired (its epic deactivated / all children done,
714    // x-a4dc K2). An EventEmitter emit, so a first-class registered kind.
715    "active_backlog_mission_retired",
716    // Harness-aware dispatch guard (walker-emitted, x-3e70): the shared node
717    // chokepoint deferred a node to a foreign harness that owns / is working it
718    // (a foreign-tagged claim, a codex/gemini branch, or a foreign worktree)
719    // instead of default-spawning a claude worker. Unlike the journal-based
720    // active_backlog decision events above, this is an EventEmitter emit, so it
721    // is a first-class registered kind.
722    "dispatch_deferred",
723    // Meta (daemon/worker-emitted)
724    "event_payload_too_large",
725    // Inside-leg state push (daemon-emitted, inside-out E3.2): a per-turn hook
726    // stored its latest {working|blocked|done} on the matching claude row, or the
727    // daemon dropped a report (stale seq / unknown session) without storing it.
728    "inside_leg_report",
729    "inside_leg_report_dropped",
730    // Ordered exit teardown (daemon-emitted, inside-out E3.3): a claude row with
731    // an inside-leg report is going Exited; the completion is published before
732    // the registry clears the report (AC-X2-4).
733    "inside_leg_completed",
734    // Buffer-on-early-push (daemon-emitted, inside-out E3.3): a report arrived
735    // before its session's row existed and was held in the pending buffer, then
736    // flushed onto the row at creation.
737    "inside_leg_report_buffered",
738    "inside_leg_buffer_flushed",
739    // Screen-manifest fallback rung (daemon-emitted, scrape sweep): a scraped
740    // verdict was stored/refreshed/cleared on a hook-less mux row, or a
741    // provider's manifest failed to load.
742    "screen_state_change",
743    // NOTE: the a2a status-breakpoint kinds (task_started/task_done/blocked/
744    // run_summary, x-dbaf) are NOT registered here. They are Python-defined in
745    // cli/src/fno/events/schema.yaml; the parity gate partitions names (a kind
746    // in both the Python schema and this Rust registry is a COLLISION). finalize
747    // emits run_summary via a custom envelope writer (not the registered
748    // `.emit()` path), so the production-emit-kind guard does not require it.
749];
750
751/// Build the unified (x-2901) events.jsonl envelope JSON Schema and the
752/// `status-v1` AgentState schema as static JSON objects.
753///
754/// This mirrors `schemas/events-v3.json` (single envelope) and
755/// `schemas/status-v1.json`. The hand-rolled approach is
756/// chosen to avoid pulling in `schemars`; it MUST be accompanied by the
757/// struct-drift unit test in `src/bin/client.rs` that asserts every
758/// `AgentState` field key is present in the emitted status schema properties.
759///
760/// Returns a JSON object suitable for printing via `--emit-schema`:
761/// ```json
762/// {
763///   "envelope": { <unified events-v3 schema> },
764///   "status": { <status-v1 schema> },
765///   "event_kinds": ["agent_spawned", ...]
766/// }
767/// ```
768pub fn emit_schema_json() -> serde_json::Value {
769    use serde_json::json;
770    json!({
771        "envelope": {
772            "$comment": "Unified events.jsonl envelope (x-2901). Emitted by crates/fno-agents/src/events.rs; structurally equal to schemas/events-v3.json after doc-key stripping (the parity gate diffs them).",
773            "type": "object",
774            "required": ["ts", "type", "source", "data"],
775            "properties": {
776                "ts": {
777                    "type": "string",
778                    "description": "UTC RFC3339 timestamp with millisecond precision and Z suffix"
779                },
780                "type": {
781                    "type": "string",
782                    "description": "Event type name; the daemon kinds live in KNOWN_EVENT_KINDS (see event_kinds below)"
783                },
784                "source": {
785                    "type": "string",
786                    "anyOf": [
787                        { "enum": ["active-backlog", "approvals", "backlog", "daemon", "fno-loop", "hook", "megatron", "megawalk", "migration", "observer", "skill_diff", "subagent", "target", "test"] },
788                        { "pattern": "^(worker|stream-worker):.+$" }
789                    ],
790                    "description": "Producer identity: a fixed-string source or a per-agent worker (worker:<id> / stream-worker:<id>)"
791                },
792                "data": {
793                    "type": "object",
794                    "description": "Per-type payload object"
795                }
796            },
797            "additionalProperties": true
798        },
799        "status": {
800            "$comment": "AgentState schema v1. Derived from crates/fno-agents/src/state.rs AgentState struct.",
801            "type": "object",
802            "required": ["schema_version", "short_id", "status"],
803            "properties": {
804                "schema_version": {
805                    "type": "integer",
806                    "const": 1
807                },
808                "short_id": {
809                    "type": "string"
810                },
811                "status": {
812                    "type": "string",
813                    "enum": [
814                        "spawning", "ready", "idle", "busy", "live",
815                        "restarting", "orphaned", "failed", "exited", "permanent_dead"
816                    ]
817                },
818                "ready": {
819                    "type": "boolean",
820                    "default": false
821                },
822                "last_message_at": {
823                    "type": ["string", "null"]
824                },
825                "last_reply": {
826                    "type": ["string", "null"]
827                },
828                "restart_count": {
829                    "type": "integer",
830                    "minimum": 0,
831                    "default": 0
832                },
833                "last_restart_at": {
834                    "type": ["string", "null"]
835                },
836                "pty": {
837                    "oneOf": [
838                        { "type": "null" },
839                        {
840                            "type": "object",
841                            "required": ["active", "drive_active"],
842                            "properties": {
843                                "active": { "type": "boolean" },
844                                "drive_active": { "type": "boolean", "default": false },
845                                "drive_session_id": { "type": ["string", "null"] },
846                                "drive_mode": { "type": ["string", "null"] },
847                                "last_heartbeat_at_monotonic_ns": { "type": ["integer", "null"] }
848                            },
849                            "additionalProperties": false,
850                            "if": {
851                                "properties": { "drive_active": { "const": true } },
852                                "required": ["drive_active"]
853                            },
854                            "then": {
855                                "required": ["drive_session_id", "drive_mode"],
856                                "properties": {
857                                    "drive_session_id": { "type": "string" },
858                                    "drive_mode": { "type": "string" }
859                                }
860                            }
861                        }
862                    ]
863                }
864            },
865            "additionalProperties": false
866        },
867        "event_kinds": KNOWN_EVENT_KINDS
868    })
869}