Skip to content

v0.25.0

Latest

Choose a tag to compare

@github-actions github-actions released this 11 Aug 01:49
· 16 commits to main since this release
5919ad0

Mermaid 0.25.0

Added

  • mermaid task <id> --send "<text>" -- you can talk to a task that is
    already running.
    Attaching to a daemon task could watch it and kill it,
    and that was all: subscribe_task streams a task's events and
    cancel_task fires its token, but nothing could put a message into a
    run in flight. Noticing halfway through a twenty-minute task that you also
    wanted the tests checked meant cancelling it or waiting it out and
    starting again from --resume.

    The run now publishes an EngineHandle -- its mailbox and its event bus --
    the moment its engine exists, before the first model call. The daemon
    registers it alongside the cancellation token it already kept, and drops
    it with the task's event stream. The mailbox is the SAME channel every
    effect result arrives on, which is the design and not an implementation
    detail: a prompt sent from outside is indistinguishable from one the run
    produced itself, so it goes through the same reducer, the same stale-turn
    filter, the same recorder, and the same event log, and queues behind the
    live turn exactly as a typed one does. There is no second way into the
    state.

    A task that is not running says so and points at mermaid run --resume,
    rather than accepting a message with nowhere to go. The send is
    non-blocking on the daemon's side, so a run that is behind on its own
    effects cannot stall every other client.

Changed

  • Every provider streams through one read loop, and a slow reader now
    reaches the socket instead of a queue.
    Five adapters each carried their
    own copy of the same loop -- read a chunk, cap the reassembly buffer, split
    frames, dispatch, decide whether the stream ended or was cut -- and only the
    dispatch step was ever about the provider. The copies had drifted: Ollama
    flushed the un-terminated frame left when a body closes mid-frame and the
    four SSE adapters dropped theirs, so a server that ends its body directly
    after data: {...} without the blank line loses its final frame on four
    providers out of five. Nobody chose that; it is a Framing decision written
    down in one place now, with the reason next to it.

    What you can observe. Between the adapters and the screen sat an unbounded
    staging channel and a relay task spawned per turn, there to stop a Done
    event from overtaking a tool call the model had asked for -- which would
    make the agent silently skip running it. The adapters' read loops were
    already async, so they now await the send directly and the ordering holds by
    construction, with no second channel to reorder anything. The bounded
    channel that was always documented as the backpressure finally is it: a turn
    whose consumer falls behind stalls the read and fills the provider's TCP
    window, rather than growing a queue in memory. A turn also no longer leaks a
    spawned task if it is cancelled at the wrong moment, because there is no
    longer a task to leak.

    Meta joins the other four as a real adapter rather than a provider that
    hand-rolled one, which is what removes the fifth copy of the loop. The wire
    formats are now covered by one conformance suite: recorded response bodies
    per provider, asserted in shared terms, so that Anthropic's max_tokens,
    Gemini's MAX_TOKENS, OpenAI's length and Ollama's done_reason: "length" are provably the same fact to everything above them. Each scenario
    runs twice, the second time one byte at a time, which is the first coverage
    any adapter has had for a network chunk boundary landing inside a frame.

  • The driving loop is a value now, and a timed-out headless run stops
    leaking its MCP children.
    update(State, Msg) -> (State, Vec<Cmd>) is the
    whole product; driving it is five lines -- stamp the clock, reduce, route the
    commands, stop on exit -- and those five lines were written out longhand in
    six places, each with its own spelling of the loop around them. The cost was
    never the duplication, it was that fixes landed once per copy: #76 (a
    timed-out child dropped its effect runner mid-flight and leaked the MCP
    servers it was still holding) was fixed in the subagent's loop by moving the
    deadline into the select!, and mermaid run kept the identical bug,
    unfixed, because it was a different function -- its timeout() wrapper
    returned Err through ? while the runner was still sitting on the caller's
    stack.

    crate::engine::Engine now owns the reducer state and the effect sink and
    exposes that loop once, with three seams for the axes the callers really
    differ on: where a Cmd goes (EffectSink), what watches each message
    before the reducer consumes it (StepObserver), and when the loop stops
    (DrivePolicy -- which turns "abort" versus "inject CancelTurn and give
    the turn 15 seconds to unwind" from two undocumented behaviours into two
    named ones). The deadline is a select! arm, so a timed-out run still owns
    its state and still reaches its own shutdown path.

    The kernel is deliberately synchronous and observer-free, because --replay
    folds a recorded log with no tokio runtime in sight; it now names dropping
    the emitted commands as a policy (DropEffects) rather than leaving it to a
    let (next, _cmds).

    All four drive loops are callers: the replay fold, mermaid run, the
    subagent's child, and the interactive TUI -- which keeps its own select!,
    because terminal events and the $EDITOR round-trip are genuinely
    run-loop-owned, but now feeds one step call, and reaches its stated
    "~30-line main loop" for the first time. What each of them contributes is
    now a named thing rather than an inlined one: the --record writer and the
    RunEvent projection are observers, Cmd::ComposeInEditor is a sink that
    peels it off, and the child's progress relay is the observer wrapping the
    state machine it already had.

    --replay, mermaid run, --record, the subagent, and the daemon's NDJSON
    and subscribe_task streams behave exactly as before -- the RunEvent wire
    is untouched and still v1. See docs/design/engine-extraction.md; the actor
    form (send/subscribe), which daemon attach and multi-session need,
    follows.

  • subscribe_task replays what an attach missed instead of joining
    from-now.
    mermaid task <id> --follow used to start at whatever
    happened next: attach at minute nine and you saw nine minutes of
    silence, and never the session_started line that names the session --
    the same empty-handed attach the terminal path was fixed for, still
    shipping for every live one. The session event log is the durable record
    of everything before the attach, so the daemon now reads it and replays
    a catch-up first: identity, then the transcript committed so far as
    coarse text / reasoning / tool lines (one per committed message
    rather than the deltas that produced it), then the newest checklist.
    The ack gained a replayed count so a consumer that only wants what
    happens from now skips exactly that many lines; the RunEvent wire
    itself is unchanged and still v1.

    Reaching the log mid-run needed the key to exist mid-run:
    tasks.conversation_id was stamped at terminal status, and is now
    stamped when the run announces its session, with the end-of-run write
    kept as the authority. mermaid task <id> shows the conversation while
    the task is still running as a result. The receiver is attached before
    the log is read, so a message committed during the read is replayed and
    also delivered live -- an overlap bounded to that one message, and the
    right way round, since repetition is recoverable by a consumer and a
    hole is not.

  • Modal precedence is one resolver, and every picker shares one
    navigation core.
    Which surface owned a keystroke used to be a 25-deep
    chain of early-return guards in handle_key; which pane owned the bottom
    of the screen was TWO hand-maintained ladders in the render layer (one
    sizing the zone, one drawing it) that had to be edited in lockstep --
    the family behind the vanished-composer bug. State::focus() now names
    the precedence once (approval > question > confirm > picker > composer),
    key routing dispatches on it, and the render layer's new single
    BottomPane decision derives from the same resolver -- what draws and
    what receives keys are one authority, and the slash palette's filtered
    entries are computed once per frame instead of once per ladder.

    The four UiMode pickers (model, conversations, rewind, plan config)
    each hand-rolled the same Up/Down/Enter/Escape machine -- the
    duplication family behind the paste-into-the-file-picker bug. The
    machine now exists once (picker::picker_step); each handler keeps only
    its confirm semantics and extra keys (query typing, value cycling). The
    approval-modal and confirm-modal key handlers extracted verbatim into
    named functions on the way. Frame output is pinned unchanged by the
    snapshot and PTY suites.

  • One store handle per process on the hot paths. Every runtime-store
    touch used to run its own RuntimeStore::open_default() -- the per-call
    open, ACL probe, and migration check repeated on paths that fire per tool
    call (tool-run bookkeeping, capability-probe caching, process upserts,
    compaction rows). Those now share one process-wide handle via the new
    with_shared_store, which opens lazily, never caches an open failure,
    and evicts the handle after an operation error -- so a transient failure
    (this store has lived on a drive that drops off the bus) self-heals
    exactly like the open-per-call world did, minus the churn. One-shot
    paths (CLI subcommands, startup, the daemon's own ownership of its
    store) deliberately keep direct opens: the helper's doc states the
    policy, and the store remains multi-process by design -- WAL plus the
    owner-kind column keep a live session and a running daemon out of each
    other's rows, which this change documents at the type.

  • The tool-dispatch seam wears its three hats as three types. One tool
    call used to cross the reducer/effect boundary as eleven loose fields on
    Cmd::ExecuteTool, a 24-argument dispatch_execute_tool, and a 16-argument
    ExecContext::new followed by eight post-construction field-sets -- turn
    plumbing, session identity, and policy inputs fused into one bag, with the
    "field-set after construction on the live path" convention documented in
    comments because the types could not say it. The seam now names them:
    ToolDispatch (domain -- everything the reducer stamps from live session
    state), TurnSignals (the owning scope's cancellation, background signal,
    and shared web budget), and ToolServices (the runner-bound config, task
    ownership, and interaction back-channels). ExecContext::assemble builds
    the flat context every tool already reads in one total step -- no
    post-construction mutation -- and the six tool tests that each hand-rolled
    the 16-argument constructor now share the one test helper. Checklist
    evidence recording degrades to a graceful no-op without a broker instead of
    requiring one, matching every other optional service.

  • The pure MVU core no longer depends on mermaid-runtime -- the crate
    stack points one way.
    mermaid-domain's manifest-omission enforcement
    ("a reducer that wants to await cannot, because the runtime is not a
    dependency") was true for direct use but not transitively: the crate
    depended on mermaid-runtime for shared vocabulary, pulling rusqlite and
    the OS surface underneath the "pure" core. The vocabulary moved to the
    bottom crate instead: the safety-policy types (SafetyMode, RiskClass,
    ActionRequest, PolicyDecision, the floors, HostShell, the denial
    markers) now live in mermaid_model::safety, and the durable-store row
    DTOs in mermaid_model::records. Doing that flipped one edge the other
    way -- mermaid-model itself depended on mermaid-runtime for redaction
    and data_dir -- so those moved down too (mermaid_model::utils::redact,
    mermaid_model::utils::dirs), completing the inversion:
    model < runtime < domain < cli, acyclic, with mermaid-runtime
    re-exporting every name its own API surfaces so no caller path changed.

    Two enforcement artifacts record the new shape. The layering guard's
    may_use for mermaid-domain and src/render drops runtime entirely
    -- naming mermaid_runtime from the pure layers is now a guard failure,
    not just a taste violation. And the release pipeline publishes in the new
    dependency order (model, runtime, domain, cli).

  • The read-only lookup family is one request/response envelope. Eleven
    Cmd variants (ListConversations, LoadConversation,
    ListAvailableModels, ListProjectFiles, and the seven
    /runtime-family listings) paired 1:1 with eleven Msg variants, each
    pair carrying its own reducer arm and its own spawn-and-wrap block in the
    effect dispatcher -- identical shape everywhere: ask for a listing, get a
    value back, no turn scoping, no side effects. They are now one
    Cmd::Query(Query) answered by one Msg::QueryResult(QueryResult), with
    the request/response pairs written down once in the new query module.
    The reducer routes results through a single handle_query_result; the
    effect dispatcher runs them through a single dispatch_query (bodies
    unchanged -- conversation reads and provider discovery stay async, store
    reads and the project walk stay on the blocking pool). Cmd::tag() and
    Cmd::summary() delegate to the query, so traces and --record files
    carry the exact strings they always did, and QueryResult's variants
    keep the old Msg names so a recording reads the same one level deeper.

    Replay compatibility: recordings made before this change replay their
    query-result lines as skipped entries (the same shape any newer-build
    recording takes); everything else replays unchanged. MsgKind folds the
    four listing kinds and the runtime-store collapse into one QueryResult
    kind, with RuntimeStore kept for the generic text response.

  • The three capability sources have their precedence written down, and
    the static baseline is one constructor.
    Model capabilities come from
    three places -- live probes (provider /models, Ollama /api/show,
    cached in provider_probes), the static catalog, and the adapter's own
    advertised baseline -- but the precedence lived in scattered comments,
    and capabilities.rs still introduced itself with "for Step 1 the
    values are hardcoded; a future step can add per-model lookup or runtime
    probing" -- a future that shipped long ago. The module doc now states
    the chain (probe > catalog > adapter static) and where each source
    lives. The five adapters that each hand-built the same struct -- five
    copies of "tools on, windows None, no continuation" varying only in
    vision and reasoning enum -- now route through one
    ModelCapabilities::advertised(vision, reasoning) constructor, so
    "static windows stay unknown until live discovery" is a property of the
    constructor rather than a comment repeated per provider. Meta keeps its
    documented muse-spark family limits by explicit struct-update over the
    baseline -- the one sanctioned exception, now labeled as such.

  • policy/mod.rs splits along its seams. The last un-decomposed file
    in the policy module carried three concerns: the vocabulary (SafetyMode,
    RiskClass, ActionRequest, PolicyDecision, the floors), the engine
    that folds them into a verdict, and ~1,900 lines of end-to-end policy
    tests. The vocabulary now lives in policy/types.rs, the engine and its
    helpers in policy/engine.rs (tests ride with the engine, since a
    verdict is only meaningful end to end), and mod.rs is the gateway that
    keeps every existing crate::policy::* / mermaid_runtime::* path
    working. The now-unconsumed pub(crate) use shell::* glob is gone --
    engine and tests import what they use by name. No behavior change.

  • wrap.rs's tests moved home. The wrap extraction left its ~15 tests
    behind in chat.rs's test module — wrap.rs itself showed zero tests while
    the suite that pins its CJK widths, phantom-space, hard-break, and
    style-preservation behavior lived in a different widget's file. The tests
    (and their first_segment_text helper) now sit in render::wrap::tests,
    byte-identical; chat.rs's module keeps only chat-owned coverage. Same
    cleanup transition.rs got, applied to the remaining instance.

  • transition.rs is the turn-state machine its doc says it is. The
    module opens with "helpers that enforce invariants during turn-state
    transitions" — and then ~600 of its ~790 code lines were presentation:
    action_display_for, display_info_for, the per-tool detail shaping
    (diff summaries, web-fetch provenance lines, subagent spend), duration
    and pluralization formatting. All of it moved byte-identically, tests
    included, into action_display.rs, whose own doc says what it actually
    is: tool calls and outcomes rendered as transcript action rows. The
    state machine that gates "no follow-up model call with missing tool
    outcomes" is now a ~150-line module a reader can hold in one look, and
    the cohesion break stopped hiding behind a load-bearing doc comment. No
    behavior change; the crate-root re-exports keep every consumer's names.

  • Slash-command arity and usage text live in the command registry, not in
    the reducer.
    Fourteen commands require an argument (/task, /pause,
    /resume, /logs, /stop, /restart, /open, /approve, /deny,
    /checkpoint, /restore, /model-info, /remember, /forget), and each
    one carried a hand-typed "Usage: /…" string in its own reducer arm — 18
    literals that could drift from the palette's argument hints, because the
    registry that owns those hints carried no notion of arity. The hint's
    bracket is now load-bearing (<...> required, [...] optional): the parser
    consults it, answers a bare or blank invocation with a usage line derived
    from the registry entry, and the fourteen SlashCmd variants carry a plain
    String — the missing-argument state is unrepresentable past the parser,
    and the fourteen usage arms are one.

    Three user-visible edges moved with it. A required-arg command with a
    blank argument (/task — trailing space) now shows usage instead of
    dispatching an empty id downstream. /remember's usage line reads
    Usage: /remember <fact> (derived from its palette hint). And /restore's
    palette hint gained precision (<checkpoint-id>) so the hint and the usage
    line agree. Recordings from before this change replay unchanged except for
    bare-invocation slash lines ({"Task":null}), which replay as skipped
    entries — the shape a newer-build recording already takes.

  • A new turn-scoped message or command can no longer silently skip the
    staleness gates.
    Msg::turn_id() feeds the reducer's stale-turn filter
    and Cmd::scope_turn() feeds the effect runner's cancelled-turn tombstone
    check — and both ended in a _ => wildcard, the one construction
    update_step's own exhaustiveness guarantee could not reach. A
    turn-carrying variant added without touching those two lists would have
    compiled clean and bypassed staleness/tombstone checking entirely, which
    is precisely the bug class the filters exist to make impossible. Both
    matches now name every variant and deny the two wildcard lints the same
    way update_step does, so the next variant is a compile error until its
    author decides which side of each gate it belongs on. Cmd::is_turn_scoped
    is now defined as scope_turn().is_some() — the two were documented as
    the same set but maintained as separate matches.