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}