lernie 0.0.4

A git-backed agent harness
Documentation
//! `lernie advance <workspace> <agent>` — the §6 driver verb: one hop
//! of the workflow chain.
//!
//! Every §2.11 launch seam spawns this verb (detached, via the injected
//! launcher), the exec baton hands one hop to the next (§6), and an
//! operator runs the same verb by hand — launch after a crash, launch
//! after a deposit, and hand-run are indistinguishable, which is the
//! §6 collapse of "advance" and "resume". A hop:
//!
//! 1. **takes the lease** — an adopted predecessor fd or a fresh
//!    acquire ([`crate::prompt::inbox::baton`]); losing the acquire is
//!    the clean no-op of Writer/driver totality (§2.11).
//! 2. **delivers** — [`super::driver::deliver`]: rematerialize, stray
//!    recovery, delivery commits (§2.11).
//! 3. **derives warrant from the tree** ([`warrant`]) — no launcher
//!    decides warrant; the driver decides under the lock (§2.11). The
//!    derivation is the wire alternation itself (§2.3): a transcript
//!    tail ending user-side means a model call is due.
//! 4. **runs one step** ([`hop`]) — the same step machinery
//!    [`super::run_exchange`] drives.
//! 5. **hands off** — tools ran → [`AdvanceOutcome::ToolsPending`]
//!    carries the live lease out for the caller to exec the successor
//!    (§6 exec baton, [`cli`]); a terminal event ends the chain through
//!    the §2.11 exit protocol (deposit at the terminal event → release →
//!    epitaph-valued launches, at own agent and at the parent the
//!    deposit revived → return).
//!
//! Config resolution is **lazy** (the `resolve` closure): a no-op hop
//! exits before any config file is read or any `bz --version` guard
//! runs, so the pin-1 recursion terminator costs nothing but the probe.

pub mod cli;
mod held;
mod hop;

#[cfg(test)]
mod tests;

use super::{assembler, child_result, driver, terminal};
use crate::prompt::inbox::{self, ExecutorLock};
use crate::prompt::resolve::WorkerConfig;
use crate::prompt::{Deps, Error};
use crate::workspace::hold;
use brazen::{Content, Message, Role};
use std::path::Path;

/// What one hop found and did — derived on the fly, nothing stored.
#[derive(Debug)]
pub enum AdvanceOutcome {
    /// Another executor holds the lock: the branch is already driven —
    /// the clean no-op of Writer/driver totality (§2.11).
    AlreadyDriven,
    /// Nothing is due: empty inbox and a transcript tail with no model
    /// call pending — the §2.11 pin-1 silent exit, terminating the
    /// exit-launch recursion. The exit honours the §2.11 release rule
    /// ([`driver::release_then_reprobe`]): a deposit that raced this
    /// hop's last inbox read is launched for after the release, so
    /// "silent" never means "stranding".
    NothingToDo,
    /// The hop stepped to a terminal event and ran the §2.11 exit
    /// protocol; the chain ends here. The epitaph is not carried — the
    /// exit protocol already wrote it to disk (the deposited result
    /// message, §2.11), its single authoritative home (PRINCIPLES SSOT).
    Terminal,
    /// The step emitted `tool_use` and its tools ran: the successor hop
    /// must run. Carries the live lease for the §6 exec baton — the
    /// caller preps the successor command ([`cli`], `baton`) and execs.
    ToolsPending(ExecutorLock),
    /// The configured control held an invocation (§3.3 *Tool control*):
    /// the branch is parked mid-tool-window — no terminal, no deposit,
    /// the lease released. The whole state is disk-derived: the hold
    /// mark ([`crate::workspace::hold`]) plus the unpaired tail. A later
    /// drive of the same agent re-adjudicates ([`held`]).
    Held,
}

/// What the transcript tail warrants (§6 hop step 3).
#[derive(Debug, PartialEq, Eq)]
enum Warrant {
    /// Tail ends user-side (delivered mail, committed tool results): a
    /// model call is due.
    ModelCallDue,
    /// Tail ends assistant-side without `tool_use`, or is empty: nothing
    /// is due (§2.11 pin 1).
    NothingDue,
    /// Tail ends assistant-side with `tool_use` unmatched by committed
    /// tool results: the one non-replayable state (§6), declined loudly.
    Unpaired,
}

/// Derive warrant from the assembled wire history (§6): the alternation
/// grouping (§2.3) makes the tail's role the whole answer — committed
/// tool results compose tool-side (canonical `Role::Tool`, §2.3) and
/// delivered mail user-side, so either non-assistant tail is the same
/// observation: a model call is due.
fn warrant(messages: &[Message]) -> Warrant {
    match messages.last() {
        None => Warrant::NothingDue,
        Some(m) if matches!(m.role, Role::User | Role::Tool) => Warrant::ModelCallDue,
        Some(m)
            if m.content
                .iter()
                .any(|b| matches!(b, Content::ToolUse { .. })) =>
        {
            Warrant::Unpaired
        }
        Some(_) => Warrant::NothingDue,
    }
}

/// Run one hop against `agent_id`'s branch. `lease` is a lease the
/// caller already took (the adopted §6 baton fd); `None` acquires here
/// — losing the acquire is [`AdvanceOutcome::AlreadyDriven`]. `resolve`
/// loads the role config lazily, only once a step is warranted (`&mut
/// dyn` rather than `impl FnOnce` so the function has one instantiation
/// and one coverage record).
pub(in crate::prompt) fn run(
    workspace: &Path,
    agent_id: &str,
    lease: Option<ExecutorLock>,
    deps: &Deps<'_>,
    resolve: &mut dyn FnMut() -> Result<WorkerConfig, Error>,
) -> Result<AdvanceOutcome, Error> {
    let lock = match lease {
        Some(lock) => lock,
        None => {
            let inbox_dir = inbox::inbox_dir(workspace, agent_id);
            match inbox::try_acquire(&inbox_dir).map_err(|source| Error::ExecutorLock {
                path: inbox_dir.clone(),
                source,
            })? {
                Some(lock) => lock,
                None => return Ok(AdvanceOutcome::AlreadyDriven),
            }
        }
    };

    // A hold mark parks the branch mid-tool-window (§3.3 *Tool
    // control*), and the held entry runs **before delivery** — mail
    // delivered onto an unpaired tail would wedge between a `tool_use`
    // and its `tool_result` (§2.3 pairing), so a parked branch queues
    // its mail instead ([`held`]). A stale mark is cleared there and the
    // ordinary hop continues below.
    let lock = match hold::read(workspace, agent_id, deps.git) {
        Some(mark) => match held::resume(workspace, agent_id, &mark, lock, deps, resolve)? {
            held::Resumption::Done(outcome) => return Ok(outcome),
            held::Resumption::Stale(lock) => lock,
        },
        None => lock,
    };

    // `delivery.left` is what this executor's last inbox read under the
    // lease deliberately left pending — the §2.11 release rule's diff
    // base for every voluntary release below (the two no-op exits and
    // the terminal arm alike).
    let delivery = driver::deliver(workspace, agent_id, deps.git)?;
    let seen = delivery.left;
    let worktree = crate::workspace::agent_worktree(workspace, agent_id);
    if !worktree.exists() {
        // Torn down and no mail: quiescent, nothing due (§2.3 step 6).
        driver::release_then_reprobe(lock, workspace, agent_id, &seen, deps.launcher);
        return Ok(AdvanceOutcome::NothingToDo);
    }

    // §6 delivered-child-result circumstance: interpret any result message
    // the drain left in the inbox (deliver_result / land_compaction / a
    // gate-hold, keyed on the returning child's role). This needs the
    // workflow, so resolve once when a result is pending — a no-op hop has
    // none and still resolves nothing (lazy resolution). The resolved
    // config is reused by the step below rather than read twice.
    let mut cfg = None;
    if child_result::has_pending_result(workspace, agent_id)? {
        let resolved = resolve()?;
        child_result::interpret_pending(workspace, agent_id, &worktree, &resolved.workflow, deps)?;
        cfg = Some(resolved);
    }

    // Warrant derives from the transcript tail alone (§2.3, §6): the
    // §5.2 head/body sits ahead of the tail and must not read as
    // user-side mail warranting a model call — and the transcript-only
    // composition keeps a no-op hop config-free (lazy resolution,
    // above).
    match warrant(&assembler::transcript(&worktree)?) {
        Warrant::NothingDue => {
            // §2.11 pin 1, closed by the release rule: the silent exit
            // is silent only over an inbox this hop's own last read
            // fully accounted for — a deposit that raced that read met a
            // Busy writer probe and is owed its launch by us.
            driver::release_then_reprobe(lock, workspace, agent_id, &seen, deps.launcher);
            Ok(AdvanceOutcome::NothingToDo)
        }
        Warrant::Unpaired => Err(Error::UnpairedToolUse {
            branch: agent_id.to_string(),
        }),
        Warrant::ModelCallDue => {
            let cfg = match cfg {
                Some(cfg) => cfg,
                None => resolve()?,
            };
            match hop::step(workspace, agent_id, &worktree, &cfg, deps)? {
                hop::StepOutcome::ToolsRan => Ok(AdvanceOutcome::ToolsPending(lock)),
                hop::StepOutcome::Held => {
                    // Fresh park (§3.3 *Tool control*): the seam wrote
                    // the mark; release through the release rule and
                    // exit without a terminal.
                    driver::release_then_reprobe(lock, workspace, agent_id, &seen, deps.launcher);
                    Ok(AdvanceOutcome::Held)
                }
                hop::StepOutcome::Terminal(epitaph) => {
                    // The shared §2.11 terminal tail ([`terminal::conclude`]
                    // — the same sequence as `run_exchange`'s): finish by
                    // epitaph value, terminal-lifecycle bindings (§6),
                    // release through the release rule (a racing deposit
                    // launches whatever the epitaph), then the
                    // epitaph-valued exit launches. No terminal compaction
                    // (§2.7 — the stage is deleted).
                    terminal::conclude(
                        workspace,
                        agent_id,
                        epitaph,
                        &cfg.workflow,
                        lock,
                        &seen,
                        deps,
                    )?;
                    Ok(AdvanceOutcome::Terminal)
                }
            }
        }
    }
}