yog 0.0.47

yog: the standalone server for litany loops — the world, the balls and the conversations, behind one wire
Documentation
//! The §7.3 **step wound**: what went wrong with one step, said in words where
//! the step is rendered. Three classes, which is the whole of the vocabulary:
//!
//! - **No response** — the driver died before the model produced anything, and
//!   since bl-55d8, why.
//! - **Output limit** — the model call framed cleanly and the *turn* did not
//!   end: the request's `max_tokens` ran out mid-utterance (§4.4
//!   [`Ending::OutputLimit`], bl-fb87). Framing alone paints that step `✔
//!   complete`, which is true of the transport and a lie about the turn — the
//!   same quiet-step misreading the no-response class exists to correct, one
//!   layer up.
//! - **Refused** — the provider said no: the step settled `Failed` with an
//!   auth-shaped error, which is §8.3's [`AuthFailure`] classification exactly.
//!   The arm **wraps that answer** rather than restating it, so the Login
//!   affordance and the wound are one fact with one derivation; what the arm
//!   adds is that the refusal is now IN the vocabulary, which is what makes
//!   [`latest_wound`] total over the ways a conversation dies quietly and lets
//!   the §8.5 transcript seat one notice for all of them (bl-015b).
//!
//! The no-response class, in detail:
//!
//! Without this state such a step reads as a quiet one — `Framing::Killed`
//! paints the same ash "stopped" badge a mid-stream kill gets, over a
//! `0 attempts · 0 tok` row that looks like nothing happened, while STORIES S0
//! step 4 promises "any step failure is a rendered fact" and §7.3 that a failed
//! action is never stderr-only.
//!
//! The **state** is two observations — the same pair §3.5 already composes for
//! agent state, nothing stored (§5.1 #13):
//!
//! - **Unanswered on disk** — the step's `response.json` carries no bytes
//!   (absent or zero-length) *and* its `meta.json` is absent. litany writes
//!   `meta.json` only after the model call returns (ARCH §2.3 — its dispatch
//!   loop short-circuits on the call's own error before `write_meta`), so the
//!   pair says exactly: the call emitted nothing and the step never settled.
//! - **Nobody driving** — a live driver's newest step is legitimately
//!   unanswered for the moments between opening `response.json` and the first
//!   streamed event, so the wound is claimed only of an agent whose lock is
//!   free (§3.5). Never a false definite (§10).
//!
//! Only the newest step can be the one a driver is filling, so the liveness
//! observation gates that step alone — an earlier unanswered step is
//! unambiguous, and stays rendered as the place the conversation died.
//!
//! The **reason** is a third read of the same step directory, and it is the
//! whole of bl-55d8. litany ARCH §2.3 on `stderr.log`, verbatim: *"the adapter
//! subprocess's stderr, appended once per attempt across the model call.
//! **Empty on an ordinary run**: brazen speaks every failure in-band on stdout
//! (§4.4), so bytes here mean the adapter failed outside that contract — a
//! startup failure (a malformed brazen config, an unreadable credstore) that
//! produced no events at all."* That is this wound's class exactly, so the
//! file is not a hint about the cause — it **is** the cause, in the adapter's
//! own words, sitting in the step yog is already reading.
//!
//! **It is not a new state, and it is not a new stored fact** (bl-55d8): the
//! predicate is unchanged, the read is gated on it (a healthy step pays
//! nothing), and the words are re-read from disk every derivation like every
//! other §5.1 fact.
//!
//! **Where it is NOT.** Until bl-55d8 the banner pointed at the ops surface —
//! *"the driver's own stderr is in the activity trail below"*. For the class
//! the operator actually hit that pointer is empty: a turn continued by
//! `litany message` is driven by a child **litany** launched, not by a yog
//! detached spawn, so no §8.1 per-spawn sink exists to fold into a `-2` ops row
//! at all. The step's own `stderr.log` is the only copy, which is why the
//! sentence now carries the bytes instead of naming a place to look.
//!
//! Deliberately *not* a reproduction hatch (§8.4 `yog exec`): see §14's
//! rejection — the driver's own words are now rendered where the wound is, so
//! there is even less reason to ask the operator to re-create it.

use std::path::Path;

use crate::git_tree::{AgentState, Ending};
use crate::login::auth::AuthFailure;

/// The sentence yog renders at the wound — the §7.3 rendered fact for this
/// class. Used verbatim beside the Steps row and composed into the §11
/// Altitude-1 banner, so both surfaces say one thing.
pub const NO_RESPONSE: &str = "driver produced no response";

/// The same, for the §4.4 output-limit class (bl-fb87). It names what happened
/// to the **turn**, not to the transport, because the transport is the half
/// the framing badge already reports and the half that went fine.
pub const OUTPUT_LIMIT: &str = "output limit ended the turn";

/// What the banner adds when the step's own `stderr.log` is empty too — the
/// honest end of the trail, said outright rather than pointing somewhere that
/// has nothing either.
const MUTE: &str = "and its stderr.log is empty too — nothing on disk says why";

/// How the banner introduces the captured bytes. It names the **file**, not a
/// surface, for the §8.3 fallback-grammar reason: an operator who wants more
/// than the tail must be told where the whole of it lives.
const SPOKE: &str = "its stderr.log says:";

/// What the banner adds for the output-limit class: what the operator is
/// looking at, and the one gesture that carries it on.
///
/// It names Nudge in order to **retire** it, because §8.2 offers Nudge on
/// every other resting conversation and a control that silently disappears
/// reads as a bug. Linked litany derives `NothingDue` from a tool-free
/// assistant tail and exits without creating a step, so the honest sentence is
/// that the gesture cannot help and which one can — never a blind retry, and
/// never a new verb (bl-fb87).
const CUT_OFF: &str = "the reply stops where the model's output budget ran out, so nothing \
     more is coming. Nudge cannot resume it — send a message to carry it on.";

/// The §11 banner's leading mark. Never the only carrier — the sentence beside
/// it states the fact in words (§11 glyph doctrine).
const ALARM: &str = "⚠";

/// The §7.3 wound, its class **and its reason** — four readings of one
/// derivation, never a stored flag (§5.1 #13).
///
/// The two no-response arms are separate rather than an `Option<String>`
/// because a wound with nothing to say is a real, distinct answer (a SIGKILL
/// mid-call leaves an empty `stderr.log`), and `Some("")` would spell it as a
/// wound whose words are blank — one fact with two encodings, which is how
/// they drift.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Wound {
    /// Not a wound: the step answered whole, or it settled, or a driver is
    /// still filling it.
    #[default]
    None,
    /// The driver produced nothing and left no words behind.
    Mute,
    /// The driver produced nothing, and the adapter said why — the tail of the
    /// step's `stderr.log`, verbatim.
    Spoke(String),
    /// The output limit ended the turn (§4.4, bl-fb87). It carries no reason
    /// of its own: the reason IS the class, stated by the canonical finish
    /// reason rather than recovered from anybody's stderr.
    OutputLimit,
    /// The provider refused the call (§8.3, bl-015b). It carries the §8.3
    /// three-state answer whole — refused on this row, or refused with no
    /// derivable row — because that answer is [`AuthFailure`]'s entire
    /// subject and a row of this arm's own would be the second spelling that
    /// drifts. [`AuthFailure::No`] never rides here: the arm is built only
    /// from a classification that is [`offered`](AuthFailure::offered).
    Refused(AuthFailure),
}

impl Wound {
    /// Is this step the wound? The §11 Altitude-1 banner's gate and the Steps
    /// row's badge both ask exactly this and nothing finer.
    pub fn wounded(&self) -> bool {
        !matches!(self, Wound::None)
    }

    /// The whole §7.3 rendered fact, in words — glyph, the class, and the
    /// reason. One home, so the banner cannot drift from what the derivation
    /// found, and so the sentence is assertable without a frame. `Wound::None`
    /// has no sentence: the caller gates on [`wounded`](Self::wounded).
    pub fn banner(&self) -> String {
        match self {
            Wound::None => String::new(),
            Wound::Mute => format!("{ALARM} {NO_RESPONSE} — {MUTE}"),
            Wound::Spoke(words) => format!("{ALARM} {NO_RESPONSE} — {SPOKE} {words}"),
            Wound::OutputLimit => format!("{ALARM} {OUTPUT_LIMIT} — {CUT_OFF}"),
            // The sentence is §8.3's, not a fourth one built here: the
            // classification that decides this arm already owns the wording,
            // and one home is the whole reason the arm wraps the answer.
            Wound::Refused(auth) => auth.banner(),
        }
    }
}

/// Read one step's wound from its own bytes. The **refusal is asked first**
/// and its answer is [`crate::login::auth::classify`]'s, unchanged: a provider
/// that said no settled the step with an error event, so it can never be one
/// of the unanswered classes below and the two questions are disjoint by
/// construction rather than by an ordering rule. What `classify` cannot know
/// is the provider **row** — that needs the agent's governing config, one git
/// read for the whole view — so it answers `Unrouted` here and
/// `steps_view`'s own `route_auth` upgrades it.
///
/// `response`, `meta_present` and
/// `ending` are the reads [`summarize`](super::summarize) already made —
/// `meta_present` is the *existence* of `meta.json`, not its parse, since a
/// malformed meta still means the step settled, and `ending` is the §4.4
/// classifier's semantic half off the same response bytes (never a second
/// walk, §15 Y13).
///
/// The two classes are disjoint by construction: a step with no response bytes
/// has no settled tail to read an [`Ending`] out of.
///
/// The `stderr.log` read is **gated on the predicate**: an ordinary step pays
/// one comparison and no syscall, so attaching the reason costs a healthy
/// conversation nothing. Both bounds on how much is read are borrowed, not
/// invented — [`crate::opslog::detached::captured`] for how much of a capture
/// file yog ever reads, [`crate::opslog::rows::stderr_tail`] for how much of a
/// stderr a *surface* says.
pub(super) fn read(step: &Path, response: &[u8], meta_present: bool, ending: Ending) -> Wound {
    let refusal = crate::login::auth::classify(response);
    if refusal.offered() {
        return Wound::Refused(refusal);
    }
    if !response.is_empty() || meta_present {
        return if ending == Ending::OutputLimit {
            Wound::OutputLimit
        } else {
            Wound::None
        };
    }
    let captured = crate::opslog::detached::captured(&step.join(super::records::STDERR_FILE));
    let words = crate::opslog::rows::stderr_tail(captured.trim());
    if words.is_empty() {
        Wound::Mute
    } else {
        Wound::Spoke(words)
    }
}

/// Is a driver at work on this agent (§3.5)? Then its newest step is allowed
/// to be unanswered — a model call in flight, not a wound.
pub(super) fn driven(state: AgentState) -> bool {
    matches!(state, AgentState::Live | AgentState::InFlight)
}

/// The **second** reason the newest step's unanswered shape may still be a call
/// in flight (§7.2, bl-90bf/bl-18e8; judged here since bl-776a): the liveness
/// half of [`driven`] is younger than its own catch-up latency.
///
/// The no-response wound's two halves do not share a clock. Its disk half is
/// read fresh at every ask; its liveness half rides the last published
/// snapshot, and a driver *taking* its flock emits no fs event — so for the
/// whole of [`Cadence::wound_grace`](crate::app::Cadence::wound_grace) after a
/// call starts, a genuinely-in-flight empty step reads as a wound. A structural
/// TOCTOU, not a bad classifier, and the fix is time: **a wound is stated only
/// once the reading that would contradict it has had time to arrive.** The wait
/// was a render-layer gate with no consumer in this crate and no carrier to a
/// seat, so every seat either hard-coded a window or flashed the alarm the
/// grace exists to prevent; spent here, the wound crosses **already-judged**
/// and a seat holds no period at all.
///
/// The anchor is the step's own `request.json` mtime — litany writes it
/// immediately before invoking the model, so it is that call's start (§5.1 #28,
/// the stamp `call_start_unix` already reads) and a fact about the world rather
/// than about any observer's first sight. No stamp means nothing on disk says
/// the step is young, and the wound stands.
///
/// **Only the unanswered classes** ([`Wound::Mute`]/[`Wound::Spoke`]) wait:
/// they are the two whose truth depends on the stale half. A refusal and an
/// output limit are settled on disk the instant they are written. Whole
/// seconds, rounding **down**: the grace bounds how long a healthy call may
/// look wounded, never how long a real wound is held back. A **zero** grace is
/// therefore no window at all rather than a special case, and a stamp *ahead*
/// of the caller's clock reads as an age of zero rather than a negative one —
/// §7.2's own convention for a derivation stamped after the reader (a clock
/// wound back, two boxes a second apart), and the conservative arm either
/// way.
pub(super) fn in_flight_window(
    step: &Path,
    wound: &Wound,
    now_unix: i64,
    grace: std::time::Duration,
) -> bool {
    if !matches!(wound, Wound::Mute | Wound::Spoke(_)) {
        return false;
    }
    let grace_secs = i64::try_from(grace.as_secs()).unwrap_or(i64::MAX);
    crate::git_tree::mtime_unix(&step.join(super::REQUEST_FILE))
        .is_some_and(|started| now_unix.saturating_sub(started).max(0) < grace_secs)
}

/// The agent's **latest** step's wound — the §11 Altitude-1 banner's input,
/// read off an already-built [`super::StepsView`] (the one owner of the
/// per-step reading) exactly as the Login banner reads its own. It takes the
/// view, not the disk: the shell declares one standing `Query::Steps` that this
/// banner and the Steps tab share (REMOTE §9.7, bl-13f9), and a predicate that
/// re-read the whole steps tree per frame was the chat pane's frame-time cost.
pub fn latest_wound(steps: &super::StepsView) -> Wound {
    steps.steps.last().map_or(Wound::None, |s| s.wound.clone())
}