From 3ac2746cb500940a22c41c6fa8cbd5abf0eac63b Mon Sep 17 00:00:00 2001 From: Yogthos Date: Sun, 27 Sep 2026 17:59:06 -0400 Subject: [PATCH] Show start refusals and the real model in the TUI, stacks for failed runs, route with ruuter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting a run from the TUI blinked "starting…" and showed nothing when the server refused it: every successful poll cleared :error, and start! polls straight after the refusal. A poll now clears only an error a lost connection raised, and a refused start also prints its full reason in the conversation, since the status strip clips it. The footer showed the model a run started on, even after it ended, and gauged the context fill against the server default's window. The run detail now carries the run's provider, model and window with any live switch applied, and the project endpoint the provider alias. The footer shows the running run's model, otherwise the one a start would use, and both gauges use the run's window. A provider switch named without a model no longer keeps the previous switch's model. Run ab935047 died opening the siblings an escalation asked for: a store call inside a two-collection mapv parked on the DB lock, which jolt raised on until 0.8.13 (jolt-lang/jolt#1142). 0.8.13 is now the minimum, so the lazy-body ratchet and the prompt rule telling cells to avoid it are gone. A failed run's :run-error note now names the cell that threw rather than the one before it and carries its stack, and the supervisor stream of a resumed run reads it with the stage crashes, frames included. The HTTP server routes through ruuter. Handlers are wrapped so a redefined var still answers the next request, and the table stays a def because ruuter caches compiled tables by value. proc_test's sweep used a fixed process name, so two suites on one machine killed each other's child; the name is now per process. --- deps.edn | 14 ++- docs/RFCS/RFC-013-cancellation.md | 36 +++--- resources/cells/decompose.clj | 10 -- resources/cells/oversight.clj | 20 ++- resources/gates.edn | 13 ++ resources/manual.edn | 9 +- resources/prompts/system-structure.md | 2 +- src/samizdat/agent/beam.clj | 41 ++++--- src/samizdat/agent/live.clj | 9 +- src/samizdat/api/control.clj | 8 +- src/samizdat/api/runs.clj | 42 ++++++- src/samizdat/server.clj | 170 +++++++++++++------------- src/samizdat/store/knowledge.clj | 6 - src/samizdat/util.clj | 16 ++- test/samizdat/base_test.clj | 91 +------------- test/samizdat/beam_cancel_test.clj | 15 +-- test/samizdat/beam_test.clj | 58 +++++++++ test/samizdat/decompose_run_test.clj | 13 +- test/samizdat/event_stream_test.clj | 13 +- test/samizdat/live_model_test.clj | 17 +++ test/samizdat/oversight_test.clj | 19 +++ test/samizdat/proc_test.clj | 10 +- test/samizdat/server_test.clj | 59 +++++++++ test/samizdat/tui_readmodel_test.clj | 33 +++++ test/samizdat/tui_state_test.clj | 28 +++++ test/samizdat/tui_widgets_test.clj | 42 +++++++ tui/samizdat/tui/state.clj | 27 +++- tui/samizdat/tui/widgets.clj | 33 ++++- 28 files changed, 569 insertions(+), 285 deletions(-) diff --git a/deps.edn b/deps.edn index fec11071..81373e6d 100644 --- a/deps.edn +++ b/deps.edn @@ -1,7 +1,12 @@ {;; jolt.ffi/write takes the value BEFORE the offset as of jolt 0.8.0, and the ;; two spellings are both integers — an older runtime writes to the wrong ;; place rather than failing. Declare the floor so it refuses instead. - :jolt/min-version "0.8.3" + ;; 0.8.13: a fiber may park inside a lazy seq's realization (mapv over two + ;; collections, filterv, for, doall over map) — before it, a store call + ;; there raised whenever another fiber held the connection lock, which is + ;; what killed run ab935047 — and a caught exception carries its stack, + ;; which a failed run's record hands the agent (jolt-lang/jolt#1142). + :jolt/min-version "0.8.13" :paths ["src" "resources"] @@ -33,6 +38,13 @@ :git/tag "v0.7.8" :git/sha "124a7399641e409a52a73d2eaa072f031ebbbe38"} + ;; the HTTP server's routing: a table of route maps, best-match (a + ;; literal segment beats a parameter), no dependencies of its own. + askonomm/ruuter + {:git/url "https://git.nmm.ee/asko/ruuter.git" + :git/tag "v2.1.1" + :git/sha "263f868cf19e73ccda7cda6b4d4af1d8fe504749"} + ;; durable sessions: jdbc.core over the system libsqlite3 via jolt.ffi jolt-lang/db {:git/url "https://github.com/jolt-lang/db" diff --git a/docs/RFCS/RFC-013-cancellation.md b/docs/RFCS/RFC-013-cancellation.md index fbbb190d..2a7629f0 100644 --- a/docs/RFCS/RFC-013-cancellation.md +++ b/docs/RFCS/RFC-013-cancellation.md @@ -445,23 +445,17 @@ Ebb's `doc/adr/001-fiber-affinity.md` states six disciplines for code on jolt fibers. Four of them are landmines for samizdat code the moment it parks, and they hold for `src/` and for cells alike: -- **Never park inside a lazy sequence.** Realizing a lazy seq takes a counted - lock, and a fiber cannot leave the CPU while its carrier holds one, so a - `?`, `sleep`, `via blk`, `join` or `timeout` inside a `map`, `for`, - `filter`, `keep`, `mapcat`, `lazy-seq`, `iterate` or `repeatedly` body is a - hang, not an error — and so is one inside `mapv` or `filterv`, because jolt - defines `mapv` as `(vec (apply map f colls))`, so the function runs during - lazy realization with the lock held (measured 2026-09-07 with - `jolt-locks-held`; karamazov-p3jo). The first live run on ebb died exactly - there, at the spawn handshake inside `advance-all`'s `mapv`, while the suite - stayed green because every test drove it from a plain thread, where a park - is a block and nothing asserts. Loops that park are `loop/recur`, `doseq`, - `reduce`, `run!`, and `into` with a transducer. *Enforced by* the base-test - ratchet `no-park-inside-a-lazy-body` over `src/` and `resources/cells`, - which treats `mapv`/`filterv` as lazy and knows samizdat's own parking - helpers (`cancel/start!`, `cancel/await-or-cancel`, `cancel/with-deadline`, - `llm/chat`, `critic/score!`). Tests of code that parks must run it on a - fiber (`(ebb/? (ebb/sp …))`), as `beam_cancel_test` now does. +- **A lazy sequence is no longer a place a fiber cannot park.** Until jolt + 0.8.13, realizing a lazy seq held a counted lock, so a park inside a `map`, + `for` or `lazy-seq` body — and inside `mapv` over several collections or + `filterv`, which jolt built on them — raised instead of parking + (karamazov-p3jo). The first live run on ebb died at the spawn handshake + inside `advance-all`'s `mapv`, and run ab935047 died at a store call inside + `spawn-children!`'s, because waiting on a contended `locking` is a park. + 0.8.13 fixed it (jolt-lang/jolt#1142) and is samizdat's `:jolt/min-version`, + so the base-test ratchet that enforced the rule was removed. Tests of code + that parks still run it on a fiber (`(ebb/? (ebb/sp …))`), as + `beam_cancel_test` does, because a park on the test thread is only a block. - **A continuation is bound to (thread, fiber), not fiber alone.** A timer thread can resume a main-thread continuation undetected. Never move a task's continuation across OS threads by hand; ebb's executors do it. *Unenforced*: @@ -554,10 +548,10 @@ day `src/` first requires it. - The lazy-seq audit (ADR-001 rule 5) found no park inside a lazy body on the turn path: maestro's loop is `loop/recur`, cells are called directly, the team fan-out uses `mapv`, and mycelium's lazy forms are compile-time. Child - 3cll.7's ratchet is the durable check. That audit was wrong about `mapv`: - in jolt it is lazy underneath (see the rules below, karamazov-p3jo), and the - beam's `advance-all` and `ensure-scored` parked inside one. Both are - `reduce` now. + 3cll.7's ratchet was the durable check. That audit was wrong about `mapv`: + in jolt before 0.8.13 it was lazy underneath (see the rules above, + karamazov-p3jo), and the beam's `advance-all` and `ensure-scored` parked + inside one. Both became `reduce`, and the ratchet went with the jolt fix. ## What this RFC does not cover diff --git a/resources/cells/decompose.clj b/resources/cells/decompose.clj index 9ceae2c1..0ab936f3 100644 --- a/resources/cells/decompose.clj +++ b/resources/cells/decompose.clj @@ -253,16 +253,6 @@ (defn- fan-out "Run the sub-unit solves, one at a time, in order. - A `reduce` AND NOT A `mapv`, which is what this was. Each thunk is a whole - sub-unit solve — an implementor worker branch, so `llm/chat` and a park at - every provider call — and jolt's `mapv` is `(vec (apply map f colls))`, so - its function runs while the lazy seq's counted lock is held, and a fiber - cannot leave the CPU there (karamazov-p3jo, RFC-013 ADR-001). The whole - decompose loop therefore died on its first sub-unit under ebb while the - suite stayed green, because a test drives it from a plain thread where a - park is only a block. base-test's ratchet could not see it either: the lazy - body was `#(%)`, which names no parking call lexically. - SEQUENTIAL ON PURPOSE, not merely for now. `attempt-node` takes a fresh git baseline per attempt and asks whether THIS attempt changed files, so siblings running at the same time would each be credited with the others' diff --git a/resources/cells/oversight.clj b/resources/cells/oversight.clj index dd5baa17..53ea55a8 100644 --- a/resources/cells/oversight.clj +++ b/resources/cells/oversight.clj @@ -165,10 +165,14 @@ (not-any? #(= :done (:status %)) results)))) (defn- crash-line - "A :stage-error note as the one-line form the digest's layer classifier - reads — the same shape the feature loop's stage guard accumulates." - [{:keys [stage node error]}] - (str stage (when (seq (str node)) (str "/" node)) ": " error)) + "A :stage-error or :run-error note as the form the digest's layer + classifier reads — the same shape the feature loop's stage guard + accumulates — with the note's frames under it when it has them. The frames + are what tell the classifier, and the supervisor, whether the fault is in a + cell or in the base." + [{:keys [stage node error trace]}] + (str stage (when (seq (str node)) (str "/" node)) ": " error + (apply str (map #(str "\n at " %) trace)))) (cell/defcell :oversight/gather {:doc "Read the run's health from the JOURNAL rather than from a stage's data @@ -245,7 +249,13 @@ ;; file tool made was rejected back to the branch that made it ;; (:by), in its own turn (karamazov-1a51.8). rejected (vec (remove :by (userspace/rejections))) - crashes (journal/notes conn run-id :stage-error) + ;; And the crash that ENDED the run, when this is a resume of + ;; it: beam/run! notes :run-error with the node and the frames, + ;; and a failed run is resumable, so this pass is the first one + ;; that can do anything about it. + crashes (into (journal/notes conn run-id :stage-error) + (map #(assoc % :stage "run")) + (journal/notes conn run-id :run-error)) ;; WHAT THE PROJECT HAS NOT TAKEN, until a pass of this run has ;; shown it (the :adoption-offered note the reason cell leaves). offers (when-not (journal/last-note conn run-id :adoption-offered) diff --git a/resources/gates.edn b/resources/gates.edn index 0dc9c7c2..361a49ce 100644 --- a/resources/gates.edn +++ b/resources/gates.edn @@ -297,6 +297,19 @@ supervisor before :provider-error-limit abandoned them. False starts the run anyway and leaves the outage to that limit."} + :run-error-frames + {:value 12 :kind :threshold :capability-tunable? false + :provenance ["run ab935047" "karamazov-ma00"] + :doc "How many stack frames, innermost first, a failed run's :run-error + note keeps. The frames are what the supervisor stream reads to tell + whose fault a crash is — a cell's, which it can fix, or the base's, + which it cannot — and where. The frames below the cell are the + scheduler's own (maestro, mycelium), so a dozen covers the cell and + what it called. Absent (a project's older gates.edn), every frame is + kept. jolt eliminates tail calls, so a function whose last act was + the failing call has no frame to keep; :node names the cell either + way."} + :run-start-deadline-ms {:value 120000 :kind :threshold :capability-tunable? true :provenance ["karamazov-iev2" "karamazov-5fyo"] diff --git a/resources/manual.edn b/resources/manual.edn index ac9d273e..322a6d81 100644 --- a/resources/manual.edn +++ b/resources/manual.edn @@ -299,7 +299,7 @@ {:name samizdat.store.journal/last-note :summary "The most recent note of one kind on a run, parsed back from JSON. How the supervisor stream reads a round's outcome — the feature loop's :route note carries the revision, the strategy and the soft cap — since the loop hands its facts to nobody and the journal is where the two meet."} {:name samizdat.store.journal/notes - :summary "Every note of one kind on a run, oldest first. What the stream reads :stage-error notes with — each is a crash a stage survived, and all of them are the supervisor's to look at. A :model note is written the turn a branch first learns which model the provider says is answering, and again only if that changes: {:requested :reported :was}, on the branch and turn. requested != reported is the provider substituting a model behind a 200, which nothing else shows."} + :summary "Every note of one kind on a run, oldest first. What the stream reads :stage-error notes with — each is a crash a stage survived, and all of them are the supervisor's to look at. Beside them it reads :run-error, the crash that ended a run, which a resume of that run shows the supervisor: {:error :type :node :trace}, :node the cell that threw and :trace its frames innermost first (gates.edn :run-error-frames). jolt eliminates tail calls, so a function whose LAST act was the failing call leaves no frame: a short trace is missing its tail callers, not wrong, and :node still names the cell. A :model note is written the turn a branch first learns which model the provider says is answering, and again only if that changes: {:requested :reported :was}, on the branch and turn. requested != reported is the provider substituting a model behind a 200, which nothing else shows."} {:name samizdat.store.interventions/kinds :summary "Every directive that can be queued against a run, with what each does — a person's at the control API, the supervisor's through `intervene`, the reflex's. Three boundaries own them: a branch's steer boundary (message, review), the beam's round top (cull, fork, retract, extend, pause, resume), and the feature loop's directives stage (switch, budget, stop). Each drain leaves alone what it does not own."} {:name samizdat.store.journal/artifacts @@ -340,10 +340,9 @@ {:group "Cancellation and parking (RFC-013)" ;; Every compiled manifest is cancellable: the check runs before each cell, ;; and a Cancelled raised inside a cell passes through every catch. What a - ;; cell author has to know is small, and it is these five plus two rules: - ;; never park inside a lazy body (map/for/lazy-seq — a hang, not an error; - ;; use mapv, doseq, loop/recur), and never spawn a raw `future` in a cell - ;; (it escapes the run's cancellation tree; fan out with ebb's join). + ;; cell author has to know is small, and it is these five plus one rule: + ;; never spawn a raw `future` in a cell (it escapes the run's cancellation + ;; tree; fan out with ebb's join). :entries [{:name samizdat.cancel/check! :summary "Throw Cancelled if the running turn has been cancelled; a no-op off a task. Call it in any loop a cell writes that can run long."} diff --git a/resources/prompts/system-structure.md b/resources/prompts/system-structure.md index f6df6cb1..022207a5 100644 --- a/resources/prompts/system-structure.md +++ b/resources/prompts/system-structure.md @@ -7,7 +7,7 @@ - **Plug in, don't graft on.** New behavior should attach through the existing seams — a `defmethod` on a multimethod, a cell in a workflow, a small namespace another requires — not by editing the middle of a big file. If the only way to add something is to wedge it into a monolith, the monolith is the thing to fix first. - **Test each unit where it lives.** A small namespace gets a small test namespace beside it. You verify a piece with `eval` while writing it, then pin it with a test. -{% if self-hosting %}**Cells are a library of things the harness can do; a workflow arranges them to solve a problem.** The harness's own behavior — the agentic loop itself — is a mycelium workflow: a graph of cells, each a small unit with declared inputs, outputs, and effects, wired by edges and dispatch. Think of the cells as a growing library of capabilities, like Lego pieces: each does one transform and assumes nothing about the workflow it sits in, so the same cell drops into different workflows unchanged. Solving a problem is usually arranging existing cells into a workflow, or adding one new cell to the library and plugging it in — not writing a special case buried in existing code. So when you build a feature, prefer to add a reusable cell that other workflows can also use, and compose the solution from the library rather than growing a monolith. Two rules for a cell body, because a cell runs on the turn's fiber and the turn can be cancelled: never park (`cancel/sleep!`, `?`, `via blk`) inside a lazy body — `map`, `for`, `lazy-seq` — which hangs instead of failing, so realize with `mapv`, `doseq` or `loop/recur`; and never spawn a raw `future`, which escapes the run's cancellation — fan out with ebb's `join`. The manual's "Cancellation and parking" group lists the five helpers. +{% if self-hosting %}**Cells are a library of things the harness can do; a workflow arranges them to solve a problem.** The harness's own behavior — the agentic loop itself — is a mycelium workflow: a graph of cells, each a small unit with declared inputs, outputs, and effects, wired by edges and dispatch. Think of the cells as a growing library of capabilities, like Lego pieces: each does one transform and assumes nothing about the workflow it sits in, so the same cell drops into different workflows unchanged. Solving a problem is usually arranging existing cells into a workflow, or adding one new cell to the library and plugging it in — not writing a special case buried in existing code. So when you build a feature, prefer to add a reusable cell that other workflows can also use, and compose the solution from the library rather than growing a monolith. One rule for a cell body, because a cell runs on the turn's fiber and the turn can be cancelled: never spawn a raw `future`, which escapes the run's cancellation — fan out with ebb's `join`. The manual's "Cancellation and parking" group lists the five helpers. {% endif %}When a task would make a file large or mix concerns, say so and choose the smaller-piece design — that judgment is part of the work, not a detour from it. diff --git a/src/samizdat/agent/beam.clj b/src/samizdat/agent/beam.clj index 2d6d03dd..4a41050f 100644 --- a/src/samizdat/agent/beam.clj +++ b/src/samizdat/agent/beam.clj @@ -78,6 +78,7 @@ [samizdat.session :as session] [samizdat.lexicon :as lexicon] [samizdat.symbolic :as sym] + [samizdat.util :as util] [samizdat.agent.oversight :as oversight] [samizdat.agent.live :as live] [samizdat.repl :as repl] @@ -192,12 +193,6 @@ branch per :critic-every window. A scoring that fails leaves the previous scores in place — stale information beats invented information." [ctx branches turn] - ;; reduce, not mapv: the critic call parks (a provider call), and in jolt - ;; `mapv` runs its function under a counted lock — measured 2026-09-07, see - ;; the base-test ratchet no-park-inside-a-lazy-body. A park there is - ;; "a fiber cannot leave the CPU while its carrier holds a counted lock", - ;; and it only shows on a fiber, which is exactly where the live driver - ;; runs and the tests did not. (reduce (fn [acc b] (conj acc (if (and (state/active? b) @@ -625,14 +620,6 @@ (handoff/forfeit! (:conn ctx) (:run-id ctx) b turn (quot (or deadline 0) 1000))) (update :timeouts (fnil inc 0)))) - ;; Both passes are `reduce`, not `mapv`: starting a task parks the - ;; driver at the spawn handshake and awaiting one parks it on the - ;; signal, and in jolt `mapv` runs its function under a counted lock - ;; (measured 2026-09-07; ratchet no-park-inside-a-lazy-body). Under - ;; mapv the first live run on ebb died here, at the spawn, with - ;; "a fiber cannot leave the CPU while its carrier holds a counted - ;; lock" — and only live, because the tests drove advance-all from a - ;; plain thread, where a park is a block and nothing is asserted. pending (reduce (fn [acc b] (let [prev (when cancelling (get @cancelling (:id b)))] (conj acc @@ -805,10 +792,20 @@ ;; (the round drives a turn manifest per branch) wraps once per level, and ;; peeling a single layer still reports "execution error" from the level ;; above. + ;; + ;; The NODE is the state whose handler threw: maestro's per-handler wrapper + ;; carries it as :current-state-id. The machine's own wrapper holds + ;; :current-state-id ::error and :last-state-id, which is the state that + ;; last COMPLETED — reading that one named :escalate for run ab935047 when + ;; :spawn had thrown. It is the fallback for an error raised between + ;; handlers (a dispatch that matched nothing). (loop [cur e, node nil, depth 0] (let [d (ex-data cur) inner (:error d) - node (or (:last-state-id d) node)] + s (:current-state-id d) + node (cond (and s (not= :maestro.core/error s)) s + (:last-state-id d) (:last-state-id d) + :else node)] (if (and (instance? Throwable inner) (< depth 8)) (recur inner node (inc depth)) {:throwable cur :node node})))) @@ -912,14 +909,18 @@ (try (journal/note! conn run-id :run-error {:data {:error (ex-message throwable) - ;; jolt's Throwable has an empty stack trace, - ;; so the type and the failing node are all - ;; there is. NOT the wrapper's ex-data: it - ;; holds the whole compiled FSM. + ;; NOT the wrapper's ex-data: it holds the + ;; whole compiled FSM. :type (some-> (:via (Throwable->map throwable)) first :type str) :node (some-> node str) - :ex-data (some-> (ex-data throwable) pr-str)}}) + :ex-data (some-> (ex-data throwable) pr-str) + ;; Where it broke, for the supervisor a + ;; resume opens: the frames say whether + ;; the fault is a cell it can fix or the + ;; base it cannot. + :trace (util/stack-lines + throwable (lexicon/policy :run-error-frames))}}) (runs/finish-run! conn run-id :failed nil) (catch Throwable _ nil)) ;; Rethrow what the cell threw, not the wrapper: the callers of run! diff --git a/src/samizdat/agent/live.clj b/src/samizdat/agent/live.clj index 0e9f95e8..405f4dc4 100644 --- a/src/samizdat/agent/live.clj +++ b/src/samizdat/agent/live.clj @@ -37,9 +37,14 @@ (defn set! "Merge `m` ({:model …}, {:provider …}, {:reasoning-effort …}) into - `run-id`'s overrides for `role` — :all when it names none." + `run-id`'s overrides for `role` — :all when it names none. + + A provider named without a model drops the model an earlier switch set: + it means that provider's declared model, and keeping the old one asked + the new endpoint for a model it does not serve." [run-id role m] - (swap! overrides update-in [run-id (or role :all)] merge m) + (swap! overrides update-in [run-id (or role :all)] + (fn [o] (merge (cond-> o (and (:provider m) (not (:model m))) (dissoc :model)) m))) nil) (defn get diff --git a/src/samizdat/api/control.clj b/src/samizdat/api/control.clj index a21d6998..5db2f292 100644 --- a/src/samizdat/api/control.clj +++ b/src/samizdat/api/control.clj @@ -41,7 +41,8 @@ [samizdat.store.grants :as grants] [samizdat.store.interventions :as interventions] [samizdat.store.journal :as journal] - [samizdat.store.runs :as runs])) + [samizdat.store.runs :as runs] + [samizdat.util :as util])) ;; run-id -> {:future f :abort (atom false)}. A run outlives the request that ;; started it, so something has to hold it. @@ -188,7 +189,10 @@ (catch Throwable e (if (cancel/control-signal? e) (log/info "run aborted:" (ex-message e)) - (log/error "run failed:" (ex-message e))) + (log/error "run failed:" (ex-message e) + (apply str (map #(str "\n at " %) + (util/stack-lines + e (lexicon/policy :run-error-frames)))))) (when-let [rid (deref promised 0 nil)] (swap! active dissoc rid) (approval/abandon! rid)) diff --git a/src/samizdat/api/runs.clj b/src/samizdat/api/runs.clj index 43510711..7e86b25d 100644 --- a/src/samizdat/api/runs.clj +++ b/src/samizdat/api/runs.clj @@ -31,6 +31,8 @@ [jolt.time] [clojure.data.json :as json] [samizdat.agent.gates :as gates] + [samizdat.agent.live :as live] + [samizdat.config :as config] [samizdat.store.db :as db] [samizdat.store.interventions :as interventions] [samizdat.store.journal :as journal] @@ -84,7 +86,40 @@ WHERE run_id = ? AND kind = 'run-seeded' LIMIT 1" run-id]))) -(defn get-run [conn run-id] +(defn run-llm + "Which model `run` is on: the provider alias and model its row recorded, + with any live switch (samizdat.agent.live) over them, and that provider's + context window — what a front end captions the run with and gauges its + fill against. The row alone is the model the run STARTED on, and the + server's own :llm is the model the NEXT run starts on; neither is this. + + The server's default provider resolves to `config`'s own :llm, which + carries what the startup probe learned (a llama.cpp -c); any other is + resolved afresh. One nothing declares any more names what the row says + and claims no window." + [config {:keys [id provider model]}] + (let [row {:provider (not-empty (str provider)) :model (not-empty (str model))} + default (:llm config) + default? (fn [p] (some #(= (some-> p name) (some-> % name)) + [(:provider-name default) (:provider default)])) + base (try (cond + (nil? (:provider row)) nil + (default? (:provider row)) (assoc default :model (:model row)) + :else (config/provider-llm config (keyword (:provider row)) + {:model (:model row)})) + (catch Throwable _ nil)) + llm (when base + (try (live/apply-to base id nil config) (catch Throwable _ base)))] + (if-not llm + (into {} (remove (comp nil? val)) row) + (into {} (remove (comp nil? val)) + {:provider (some-> (or (:provider-name llm) (:provider llm)) name) + :model (:model llm) + :context_window (:context-window llm)})))) + +(defn get-run + ([conn run-id] (get-run conn run-id nil)) + ([conn run-id config] (when-let [r (runs/get-run conn run-id)] (let [branches (runs/branches conn run-id)] {:run (-> r @@ -92,7 +127,8 @@ ;; A status of 'running' is a claim the loop makes once and never ;; revisits, so on its own it cannot distinguish a working run ;; from a dead one. These two let a client tell. - (assoc :last_progress_at (runs/last-progress-at conn run-id) + (assoc :llm (run-llm config r) + :last_progress_at (runs/last-progress-at conn run-id) :stalled (runs/stalled? conn run-id stall-threshold-ms) ;; beam_width is the repopulation FLOOR — repopulate only ;; fires below it and branch-out grows past it — so the @@ -149,7 +185,7 @@ ;; that threw (karamazov-atgu). nil until the run has ended and ;; distilled; the front ends hold no database handle, so the note ;; has to ride the detail to reach an operator. - :distilled (journal/last-note conn run-id :distilled)}))) + :distilled (journal/last-note conn run-id :distilled)})))) (defn journal-tail "Everything after `since`. The `next` cursor is what the client sends back, diff --git a/src/samizdat/server.clj b/src/samizdat/server.clj index 420f2f6c..7263ff98 100644 --- a/src/samizdat/server.clj +++ b/src/samizdat/server.clj @@ -19,10 +19,8 @@ (ns samizdat.server "The HTTP surface. - Routes are matched against a vector of [method path-or-pattern handler] - rather than through a router library. There are a dozen of them, and a - dependency that needs its :clj reader branches switched on costs more to load - than it saves. + Routes are a table of ruuter route maps, matched best-match: a literal + segment beats a parameter, so the table's order does not matter. This namespace is pure logic: redefining `handler` against a running process takes effect on the next request. See samizdat.system." @@ -42,7 +40,8 @@ [samizdat.llm.client :as llm-client] [samizdat.store.db :as db] [samizdat.system :as system] - [samizdat.userspace :as userspace])) + [samizdat.userspace :as userspace] + [ruuter.core :as ruuter])) (defn json-response ([body] (json-response 200 body)) @@ -219,6 +218,9 @@ :untracked (:untracked snap) :last_commit (:last-commit snap) :provider (some-> (get-in cfg [:llm :provider]) name) + ;; The alias config.edn declared (bonsai, deepseek-flash): what the + ;; footer names and what /model takes. :provider is its adapter type. + :provider_name (some-> (get-in cfg [:llm :provider-name]) name) :model (get-in cfg [:llm :model]) :context_window (get-in cfg [:llm :context-window]) ;; What happens when a run needs a person: refuse, block (ask), or a @@ -230,8 +232,15 @@ ;; --- routing ---------------------------------------------------------------- ;; -;; A route is [method pattern handler]. A pattern segment starting with ':' -;; binds; the bindings arrive under :path-params. +;; ruuter route maps: {:method :path :response}. A path segment starting with +;; ':' binds, and the bindings arrive under the request's :params. Matching is +;; best-match — a literal segment beats a parameter — so the order of the +;; table does not matter. +;; +;; A handler defined above is named as #(handler %), not #'handler: ruuter +;; calls :response only when it is a fn?, which a var is not, and calling +;; through the name still reads the var on every request, so a REPL +;; redefinition lands without restarting the server. (def ^:private slow-ms-cap 10000) @@ -251,103 +260,110 @@ (Thread/sleep ms) (json-response {:slept_ms ms}))) +(defn- not-found [req] + (json-response 404 {:error {:message (str "Not found: " + (str/upper-case (name (:request-method req))) + " " (:uri req)) + :type "not_found"}})) + (def routes - [[:get "/health" #'health] - [:get "/slow" #'slow] - [:get "/v1/models" #'models] - [:post "/v1/chat/completions" #'chat-completions] - [:get "/v1/harness/gates" #'gate-table] + [{:method :get :path "/health" :response #(health %)} + {:method :get :path "/slow" :response #(slow %)} + {:method :get :path "/v1/models" :response #(models %)} + {:method :post :path "/v1/chat/completions" :response #(chat-completions %)} + {:method :get :path "/v1/harness/gates" :response #(gate-table %)} ;; The terminal UI's own arrangement, so a front end that holds no ;; database handle can still see the version the agent saved. - [:get "/v1/harness/layout" #'layout-table] - [:get "/v1/harness/models" #'harness-models] + {:method :get :path "/v1/harness/layout" :response #(layout-table %)} + {:method :get :path "/v1/harness/models" :response #(harness-models %)} ;; Which project, which branch, how dirty, which model — the footer and the ;; GIT panel. Served because only this process is bound to the project. - [:get "/v1/harness/project" #'project-table] + {:method :get :path "/v1/harness/project" :response #(project-table %)} ;; The approval mode for this server session: {"mode": "block"} to have ;; the runs ask a person, "refuse" to not, null for the project's own. - [:post "/v1/harness/approval-mode" + {:method :post :path "/v1/harness/approval-mode" :response (fn [req] (let [m (:mode (body-json req))] (if-let [now (approval/set-mode! m)] (json-response {:mode (name now)}) (json-response 400 {:error {:message (str "not a mode: " (pr-str m) "; one of " - (str/join ", " (map name (sort approval/modes))))}}))))] - [:get "/v1/runs" (fn [req] (json-response (api-runs/list-runs (system/conn) - (long-param req "limit"))))] + (str/join ", " (map name (sort approval/modes))))}}))))} + {:method :get :path "/v1/runs" :response + (fn [req] (json-response (api-runs/list-runs (system/conn) (long-param req "limit"))))} ;; `(or (:status r) 200)`, the same shape resume uses: a handler that refuses ;; says so with a status, and success carries none. Answering 200 with an ;; error body let a caller checking only the code read a refusal as success. - [:post "/v1/runs" (fn [req] (let [r (control/start-run! (ctx) (body-json req))] - (json-response (or (:status r) 200) (:body r))))] - [:get "/v1/runs/:id" (fn [req] - (if-let [r (api-runs/get-run (system/conn) - (get-in req [:path-params :id]))] - (json-response r) - (json-response 404 {:error {:message "no such run"}})))] - [:get "/v1/runs/:id/journal" + {:method :post :path "/v1/runs" :response + (fn [req] (let [r (control/start-run! (ctx) (body-json req))] + (json-response (or (:status r) 200) (:body r))))} + {:method :get :path "/v1/runs/:id" :response + (fn [req] (if-let [r (api-runs/get-run (system/conn) (get-in req [:params :id]) + (system/config))] + (json-response r) + (json-response 404 {:error {:message "no such run"}})))} + {:method :get :path "/v1/runs/:id/journal" :response (fn [req] (json-response (api-runs/journal-tail (system/conn) - (get-in req [:path-params :id]) + (get-in req [:params :id]) (long-param req "since") - (long-param req "limit"))))] + (long-param req "limit"))))} ;; The run PUSHED: every journal event after the cursor (Last-Event-ID, or ;; ?since=), then each one as it lands, plus the steps and approvals that ;; are never journalled. See samizdat.api.stream. - [:get "/v1/runs/:id/events" - (fn [req] (stream/response (system/conn) (get-in req [:path-params :id]) - (stream/cursor req)))] - [:get "/v1/events" - (fn [req] (stream/response (system/conn) nil (stream/cursor req)))] + {:method :get :path "/v1/runs/:id/events" :response + (fn [req] (stream/response (system/conn) (get-in req [:params :id]) + (stream/cursor req)))} + {:method :get :path "/v1/events" :response + (fn [req] (stream/response (system/conn) nil (stream/cursor req)))} ;; The live manifest-state trace. No conn: steps are held in memory, not ;; journalled — see samizdat.steps. - [:get "/v1/runs/:id/steps" - (fn [req] (json-response (api-runs/steps-tail (get-in req [:path-params :id]) + {:method :get :path "/v1/runs/:id/steps" :response + (fn [req] (json-response (api-runs/steps-tail (get-in req [:params :id]) (long-param req "since") - (long-param req "limit"))))] + (long-param req "limit"))))} ;; One turn, whole. The branch listing drops the model's prose because it ;; is the bulk; this is how a reader gets it back, a turn at a time. - [:get "/v1/runs/:id/branches/:branch/turns/:turn" - (fn [req] (let [{:keys [id branch turn]} (:path-params req)] + {:method :get :path "/v1/runs/:id/branches/:branch/turns/:turn" :response + (fn [req] (let [{:keys [id branch turn]} (:params req)] (if-let [t (api-runs/turn-detail (system/conn) id branch (parse-long (str turn)))] (json-response t) - (json-response 404 {:error {:message "no such turn"}}))))] - [:get "/v1/runs/:id/branches/:branch" - (fn [req] (let [{:keys [id branch]} (:path-params req)] + (json-response 404 {:error {:message "no such turn"}}))))} + {:method :get :path "/v1/runs/:id/branches/:branch" :response + (fn [req] (let [{:keys [id branch]} (:params req)] (if-let [b (api-runs/branch-detail (system/conn) id branch (some-> (query-param req "notes") (str/split #",") (->> (remove str/blank?))) (long-param req "since"))] (json-response b) - (json-response 404 {:error {:message "no such branch"}}))))] - [:post "/v1/runs/:id/interventions" - (fn [req] (let [r (control/intervene! (system/conn) (system/config) - (get-in req [:path-params :id]) - (body-json req))] - (json-response (or (:status r) 200) (:body r))))] - [:post "/v1/runs/:id/abort" + (json-response 404 {:error {:message "no such branch"}}))))} + {:method :post :path "/v1/runs/:id/interventions" :response + (fn [req] (let [r (control/intervene! (system/conn) (system/config) + (get-in req [:params :id]) + (body-json req))] + (json-response (or (:status r) 200) (:body r))))} + {:method :post :path "/v1/runs/:id/abort" :response (fn [req] (let [r (control/abort! (system/conn) - (get-in req [:path-params :id]))] - (json-response (or (:status r) 200) (:body r))))] - [:post "/v1/runs/:id/resume" + (get-in req [:params :id]))] + (json-response (or (:status r) 200) (:body r))))} + {:method :post :path "/v1/runs/:id/resume" :response (fn [req] (let [r (control/resume! {:conn (system/conn) :config (system/config)} - (get-in req [:path-params :id]) + (get-in req [:params :id]) (body-json req))] - (json-response (or (:status r) 200) (:body r))))] + (json-response (or (:status r) 200) (:body r))))} ;; Questions waiting on a person: the permission gate and ask_human, which ;; share one queue because they differ only in what they carry. - [:get "/v1/runs/:id/approvals" + {:method :get :path "/v1/runs/:id/approvals" :response (fn [req] (json-response {:approvals (approval/pending - (get-in req [:path-params :id]))}))] - [:get "/v1/approvals" - (fn [_] (json-response {:approvals (approval/pending nil)}))] - [:post "/v1/approvals/:aid" + (get-in req [:params :id]))}))} + {:method :get :path "/v1/approvals" :response + (fn [_] (json-response {:approvals (approval/pending nil)}))} + {:method :post :path "/v1/approvals/:aid" :response (fn [req] (let [{:keys [decision note answers always]} (body-json req) d (keyword (or decision "deny"))] - (if (approval/decide! (get-in req [:path-params :aid]) + (if (approval/decide! (get-in req [:params :aid]) (cond-> {:decision d} note (assoc :note note) answers (assoc :answers answers) @@ -358,34 +374,16 @@ ;; operator answering a question the first already settled, or a ;; wait that expired. 409 says which, in the house style the other ;; handlers use — a short noun phrase, not a sentence. - (json-response 409 {:error {:message "approval not open"}}))))] - [:get "/v1/interventions/kinds" (fn [_] (json-response (control/kinds)))]]) - -(defn- match-path [pattern uri] - (let [ps (str/split (str/replace pattern #"^/" "") #"/") - us (str/split (str/replace (or uri "") #"^/" "") #"/")] - (when (= (count ps) (count us)) - (reduce (fn [acc [p u]] - (cond - (str/starts-with? p ":") (assoc acc (keyword (subs p 1)) u) - (= p u) acc - :else (reduced nil))) - {} (map vector ps us))))) - -(defn- match [{:keys [request-method uri]}] - (some (fn [[m pattern h]] - (when (= m request-method) - (when-let [params (match-path pattern uri)] - [h params]))) - routes)) + (json-response 409 {:error {:message "approval not open"}}))))} + {:method :get :path "/v1/interventions/kinds" :response (fn [_] (json-response (control/kinds)))} + ;; Anything unmatched, a known path under the wrong method included. + {:path :not-found :response #(not-found %)}]) (defn handler [req] (try - (if-let [[h params] (match req)] - (h (assoc req :path-params params)) - (json-response 404 {:error {:message (str "Not found: " - (str/upper-case (name (:request-method req))) - " " (:uri req)) - :type "not_found"}})) + ;; The table by name on every request, so a redefined one is what routes. + ;; ruuter compiles a table once and caches it by value, which is why the + ;; table is a def and not built here. + (ruuter/route routes req) (catch Throwable e (json-response 500 {:error {:message (ex-message e) :type "internal_error"}})))) diff --git a/src/samizdat/store/knowledge.clj b/src/samizdat/store/knowledge.clj index 28623053..10ffd23b 100644 --- a/src/samizdat/store/knowledge.clj +++ b/src/samizdat/store/knowledge.clj @@ -529,12 +529,6 @@ Successes are distilled too. A store that only remembers what went wrong teaches the next session that everything is broken." [conn findings {:keys [run-id]}] - ;; reduce, not (vec (for …)) and not mapv: the body writes to the store, - ;; and a store call waits on the connection lock when another fiber holds - ;; it — a park, which is forbidden while a counted lock is held (ADR-001 - ;; rule 1). Both `for` and jolt's `mapv` run the body under one (measured - ;; 2026-09-07; ratchet no-park-inside-a-lazy-body); the live symptom is - ;; "a fiber cannot leave the CPU while its carrier holds a counted lock". (reduce (fn [acc {:keys [kind severity detail evidence]}] (let [content (str "[" (name kind) "] " detail " " (pr-str evidence)) diff --git a/src/samizdat/util.clj b/src/samizdat/util.clj index 804856e2..927c57f0 100644 --- a/src/samizdat/util.clj +++ b/src/samizdat/util.clj @@ -22,7 +22,8 @@ The first code samizdat wrote about itself: truncate-middle was authored by the harness in a supervised self-modification run, then wired into the shell tool's output truncation." - (:require [clojure.string :as str])) + (:require [clojure.main :as main] + [clojure.string :as str])) (defn sh-quote "`s` as a single-quoted shell word, safe to interpolate into `sh -c`. @@ -80,3 +81,16 @@ head-len (quot (- max-len marker-len) 2) tail-len (- max-len marker-len head-len)] (str (subs s 0 head-len) marker (subs s (- (count s) tail-len)))))) + +(defn stack-lines + "`e`'s stack as readable lines, innermost first: `ns/fn (file:line)`, the + way a person would name the function — `samizdat.agent.beam/spawn-children!` + rather than `samizdat.agent.beam$spawn_children_BANG_`. At most `n` lines; + nil `n` keeps them all. A throwable with no stack yields []." + [e n] + (let [frames (keep (fn [[cls _method file line]] + (when cls + (str (main/demunge (str cls)) + (when file (str " (" file (when line (str ":" line)) ")"))))) + (:trace (Throwable->map e)))] + (vec (if n (take n frames) frames)))) diff --git a/test/samizdat/base_test.clj b/test/samizdat/base_test.clj index 9752452c..f91f0790 100644 --- a/test/samizdat/base_test.clj +++ b/test/samizdat/base_test.clj @@ -879,97 +879,14 @@ (is (pos? total) "if this is zero, delete prose-backlog and this test"))) ;; --- the fiber rules (RFC-013, ebb ADR-001) --------------------------------- - -(def ^:private ebb-aliases - ;; How ebb.core is referred to. The alias is a convention the ratchet - ;; depends on: a park spelled through another alias is invisible to it. - #{"m" "ebb" "ebb.core"}) - -(def ^:private park-heads - ;; Every ebb operator that parks the fiber or hands work to a thread. - '#{? ! via sleep join race any timeout reduce}) - -(def ^:private park-helpers - ;; Samizdat's own helpers that park, by alias: starting a task parks the - ;; caller at the spawn handshake, awaiting one parks it on the signal, and - ;; a provider call is a `via blk` underneath. The first live run on ebb - ;; died at `cancel/start!` inside a `mapv` (2026-09-07), which the ebb-only - ;; list above could not see. - {"cancel" '#{start! await-or-cancel with-deadline} - "llm" '#{chat} - "critic" '#{score!}}) - -(def ^:private lazy-fn-heads - ;; Lazy HOFs: their FUNCTION argument runs when the seq is realized, under - ;; a counted lock, where a park hangs the carrier. Their collection - ;; arguments are evaluated eagerly and are not the hazard. - ;; - ;; `mapv` and `filterv` are here because in jolt they ARE lazy underneath: - ;; jolt-core/clojure/core/00-kernel.clj defines mapv as (vec (apply map f - ;; colls)), so the function runs inside the lazy seq's realization, lock - ;; held. Measured 2026-09-07 with jolt-locks-held: mapv, filterv, vec/into/ - ;; doall/first/set/count over a lazy map all run f at 1; reduce, loop, - ;; doseq, run!, some, every?, group-by, sort-by, reduce-kv and `into` with - ;; a transducer run it at 0. So the loops that may park are loop/recur, - ;; reduce, doseq, run! and (into [] (map f) coll) — NOT mapv. - '#{map filter remove keep mapcat map-indexed keep-indexed take-while drop-while - mapv filterv}) - -(def ^:private lazy-body-heads - ;; Everything inside these is realized lazily. - '#{for lazy-seq lazy-cat iterate repeatedly}) - -(defn- park-call? [x] - (and (seq? x) - (symbol? (first x)) - (let [s (first x)] - (or (contains? '#{? !} s) - (and (namespace s) - (contains? ebb-aliases (namespace s)) - (contains? park-heads (symbol (name s)))) - (and (namespace s) - (contains? (get park-helpers (namespace s) #{}) (symbol (name s)))))))) - -(defn- parks-under-lazy - "Every ebb park call in `form` whose realization would happen inside a lazy - sequence body: ADR-001 rule 5, the hang that is not an error." - [form] - (letfn [(head [x] (when (and (seq? x) (symbol? (first x))) (symbol (name (first x))))) - (go [x lazy? acc] - (cond - (and lazy? (park-call? x)) (conj acc x) - (seq? x) - (let [h (head x)] - (cond - (contains? lazy-body-heads h) - (reduce #(go %2 true %1) acc (rest x)) - (contains? lazy-fn-heads h) - ;; the fn argument is lazy, the rest eager - (let [[f & more] (rest x)] - (reduce #(go %2 lazy? %1) (go f true acc) more)) - :else (reduce #(go %2 lazy? %1) acc (rest x)))) - (coll? x) (reduce #(go %2 lazy? %1) acc x) - :else acc))] - (go form false []))) +;; +;; A park inside a lazy body used to raise on a fiber, and a ratchet here +;; scanned for one. jolt 0.8.13 (the :jolt/min-version) lets a fiber park +;; there, so the rule and its scanner are gone (jolt-lang/jolt#1142). (defn- cell-files [] (sort (map str (fs/glob "resources/cells" "**.clj")))) -(deftest no-park-inside-a-lazy-body - ;; Realizing a lazy seq takes a counted lock, and a fiber cannot leave the - ;; CPU while its carrier holds one, so an ebb park inside a map/for/lazy-seq - ;; body hangs rather than fails. Loops that park are loop/recur, mapv, - ;; doseq, reduce, run!. Over src/samizdat and the cells, because both run - ;; on the turn's fiber. - (let [found (for [file (concat (src-files) (cell-files)) - form (forms-of file) - hit (parks-under-lazy form)] - {:file file :form (pr-str hit)})] - (is (empty? found) - (str "an ebb park inside a lazy body is a hang (RFC-013, ADR-001 rule 5); " - "realize the sequence eagerly (mapv, doseq, loop/recur, reduce):\n" - (str/join "\n" (map #(str " " (:file %) " " (:form %)) found)))))) - (deftest the-image-runs-the-same-nrepl-as-the-harness ;; RFC-013: the project image is started with jolt-lang/nrepl merged over ;; the project's deps so it carries interruptible-eval. The sha lives in diff --git a/test/samizdat/beam_cancel_test.clj b/test/samizdat/beam_cancel_test.clj index 614fa37b..63d712ab 100644 --- a/test/samizdat/beam_cancel_test.clj +++ b/test/samizdat/beam_cancel_test.clj @@ -131,16 +131,11 @@ ;; --- the driver runs on a fiber, and so must these tests ------------------- ;; ;; Every test above drives advance-all from the test thread, where a park is -;; a plain block and jolt asserts nothing. The live driver is an sp process -;; on a fiber, and there a park under a counted lock throws "a fiber cannot -;; leave the CPU while its carrier holds a counted lock". jolt's `mapv` is -;; (vec (map …)), so a park inside its function IS under one — which is how -;; the first live run on ebb died at the spawn handshake in advance-all -;; (2026-09-07, karamazov-p3jo) while the whole suite stayed green. Measured -;; in isolation: (ebb/sp (mapv #(ebb/? (ebb/sleep 10)) xs)) throws that -;; error, (ebb/sp (reduce …)) does not. Driven this way against the old -;; advance-all, the first of these did not fail but HUNG the calling thread -;; (fifteen minutes at 0% CPU), so a hang here is the bug, not a slow test. +;; a plain block. The live driver is an sp process on a fiber, and the first +;; live run on ebb died at the spawn handshake there while the whole suite +;; stayed green (2026-09-07, karamazov-p3jo; a jolt before 0.8.13 raised on a +;; park inside mapv). Driven this way, that bug HUNG the calling thread, so +;; a hang here is the bug, not a slow test. (deftest advancing-a-branch-whose-turn-parks-works-on-a-fiber (let [b (state/new-branch {:id "B1" :problem "p"})] diff --git a/test/samizdat/beam_test.clj b/test/samizdat/beam_test.clj index ac678b85..3b8dee63 100644 --- a/test/samizdat/beam_test.clj +++ b/test/samizdat/beam_test.clj @@ -11,6 +11,7 @@ endings, and the driver's ownership of the crash record and teardown — the two things a manifest cannot own." (:require [clojure.test :refer [deftest is testing use-fixtures]] + [ebb.core :as ebb] [mycelium.cell :as cell] [samizdat.agent.beam :as beam] [samizdat.agent.select :as select] @@ -99,6 +100,29 @@ beam/ensure-scored (fn [_ctx bs _turn] bs)] (beam/run-rounds ctx branches 1)))) +(defn- boom! [] (throw (ex-info "the round broke" {:why "test"}))) + +(deftest a-failed-run-records-the-cell-that-threw-and-its-stack + ;; The record is what the supervisor gets to fix the fault from, so it has + ;; to name the right place. :node was mycelium's :last-state-id — the state + ;; that COMPLETED — so run ab935047's record said :escalate when :spawn + ;; threw; and it had no frames, from when jolt's exceptions carried none. + (let [c (db/open! ":memory:") + rid (runs/start-run! c {:problem "p"})] + (try + (is (thrown-with-msg? Exception #"the round broke" + (drive c rid (fn [_ _] (boom!))))) + (let [e (journal/last-note c rid :run-error)] + (is (= "the round broke" (:error e))) + (is (str/includes? (str (:node e)) "advance") "the cell that threw, not the one before it") + (is (vector? (:trace e))) + (is (str/includes? (str (first (:trace e))) "samizdat.beam-test/boom!") + "frames demunged, innermost first") + (is (str/includes? (str (first (:trace e))) "beam_test.clj")) + (is (<= (count (:trace e)) (lexicon/policy :run-error-frames)) + "as many frames as gates.edn allows")) + (finally (db/close c))))) + (deftest a-shipped-branch-completes-the-run (let [c (db/open! ":memory:") rid (runs/start-run! c {:problem "p"})] @@ -632,6 +656,40 @@ (samizdat.agent.live/forget-run! rid)) (finally (db/close c))))) +(deftest escalation-opens-its-branches-on-a-fiber-whose-store-calls-wait + ;; Run ab935047 died at :beam/spawn, right after escalating, on jolt + ;; 0.8.12: spawn-children! opens the children in a two-collection mapv, and + ;; opening a branch INSERTs through db/with-conn, which parks the fiber + ;; whenever another holds the lock — a park that jolt raised on inside + ;; mapv until 0.8.13 (jolt-lang/jolt#1142). Every DB call parks here, so + ;; the contention that made it rare is made certain. + (let [c (db/open! ":memory:") + parking (fn [f] (fn [& a] (ebb/? (ebb/sleep 1)) (apply f a)))] + (try + (let [rid (runs/start-run! c {:problem "p"}) + p (lexicon/policy :escalation) + b1 (-> (state/new-branch {:id "B1" :problem "p"}) + (assoc :status :active) + (state/add-message "user" "hello")) + ctx {:conn c :run-id rid :beam-width 1 :problem "p"} + escalate (:handler (cell/get-cell! :beam/escalate)) + spawn (:handler (cell/get-cell! :beam/spawn))] + (runs/open-branch! c rid {:branch-id "B1"}) + (dotimes [i (:after-stalls p)] + (journal/record-gate! c rid {:branch-id "B1" :turn i :gate :progress-stalled + :prediction "x"})) + (let [kids (with-redefs [db/fetch (parking db/fetch) + db/fetch-one (parking db/fetch-one) + db/execute! (parking db/execute!) + db/last-insert-id (parking db/last-insert-id)] + (ebb/? (ebb/sp + (let [d (escalate ctx {:culled [b1] :all-now [b1] :turn 12})] + (:children (spawn ctx (assoc d :all-now [b1])))))))] + (is (= (dec (:to-width p)) (count kids)) "every sibling opened") + (is (= (inc (count kids)) (count (runs/branches c rid))) "and is on the record"))) + (finally (samizdat.agent.live/forget-run! (:id (first (runs/list-runs c 1)))) + (db/close c))))) + (deftest a-loop-that-does-not-compile-is-a-failed-run-not-a-running-row ;; With the row created first, a manifest that will not compile can no ;; longer refuse the request; it ends the run instead, on the record, with diff --git a/test/samizdat/decompose_run_test.clj b/test/samizdat/decompose_run_test.clj index 0ed9535b..dd729afd 100644 --- a/test/samizdat/decompose_run_test.clj +++ b/test/samizdat/decompose_run_test.clj @@ -159,15 +159,10 @@ ;; --- the fan runs on a fiber, and so must this test ------------------------- ;; ;; Every test above drives the loop from the test thread, where a park is a -;; plain block and jolt asserts nothing. The live loop is an sp process on a -;; fiber, and there a park under a counted lock throws "a fiber cannot leave -;; the CPU while its carrier holds a counted lock". jolt's `mapv` is -;; (vec (apply map f colls)), so a park inside its function IS under one -;; (karamazov-p3jo) — and the fan's function is a whole sub-unit solve, which -;; parks at every provider call. base-test's no-park-inside-a-lazy-body -;; ratchet could not see it: the lazy body was `#(%)`, which names no parking -;; call lexically. Same shape as beam/advance-all, same fix shape as its test. -;; A HANG here is the bug, not a slow test. +;; plain block. The live loop is an sp process on a fiber, and the fan's +;; function is a whole sub-unit solve, which parks at every provider call — +;; before jolt 0.8.13 that raised inside the mapv this used to be +;; (karamazov-p3jo). A HANG here is the bug, not a slow test. (deftest the-fan-runs-parking-sub-units-on-a-fiber (let [fan-out (cell-fn 'fan-out)] diff --git a/test/samizdat/event_stream_test.clj b/test/samizdat/event_stream_test.clj index bc258eb4..0ee516b3 100644 --- a/test/samizdat/event_stream_test.clj +++ b/test/samizdat/event_stream_test.clj @@ -24,6 +24,7 @@ [clojure.test :refer [deftest testing is]] [samizdat.api.sse :as sse] [samizdat.api.stream :as stream] + [samizdat.system :as system] [samizdat.approval :as approval] [samizdat.events :as events] [ring-chez.adapter :as adapter] @@ -203,9 +204,15 @@ (deftest the-server-routes-both-streams (require 'samizdat.server) - (let [match @(resolve 'samizdat.server/match)] - (is (= {:id "abc"} (second (match {:request-method :get :uri "/v1/runs/abc/events"})))) - (is (some? (match {:request-method :get :uri "/v1/events"}))))) + (let [seen (atom [])] + (with-redefs [stream/response (fn [_conn run-id _cursor] + (swap! seen conj run-id) + {:status 200}) + system/conn (constantly nil)] + (let [handler @(resolve 'samizdat.server/handler)] + (is (= 200 (:status (handler {:request-method :get :uri "/v1/runs/abc/events"})))) + (is (= 200 (:status (handler {:request-method :get :uri "/v1/events"})))))) + (is (= ["abc" nil] @seen) "one run's stream, then every run's"))) (deftest the-approval-mode-can-be-set-for-the-session (try diff --git a/test/samizdat/live_model_test.clj b/test/samizdat/live_model_test.clj index 5932fc74..0c03d2a7 100644 --- a/test/samizdat/live_model_test.clj +++ b/test/samizdat/live_model_test.clj @@ -69,6 +69,23 @@ (is (nil? (live/get rid))) (db/close conn))) +(deftest a-provider-switch-does-not-carry-the-last-model-with-it + ;; `/model deepseek-v4-pro` then `/model bonsai` merged the two, and the + ;; next request asked the local server for deepseek-v4-pro. A provider + ;; named alone means its declared model. + (let [rid (str (random-uuid))] + (try + (live/set! rid nil {:model "deepseek-v4-pro"}) + (live/set! rid nil {:provider :bonsai}) + (is (= {:provider :bonsai} (get-in (live/get rid) [:all]))) + (testing "while a provider named WITH its model keeps that model" + (live/set! rid nil {:provider :deepseek :model "deepseek-v4-flash"}) + (is (= {:provider :deepseek :model "deepseek-v4-flash"} (get-in (live/get rid) [:all])))) + (testing "and an effort switch leaves the model alone" + (live/set! rid nil {:reasoning-effort "low"}) + (is (= "deepseek-v4-flash" (get-in (live/get rid) [:all :model])))) + (finally (live/forget-run! rid))))) + ;; --- per role --------------------------------------------------------------- (deftest a-switch-can-name-the-role-it-is-for diff --git a/test/samizdat/oversight_test.clj b/test/samizdat/oversight_test.clj index 224568d5..c84afe95 100644 --- a/test/samizdat/oversight_test.clj +++ b/test/samizdat/oversight_test.clj @@ -254,6 +254,25 @@ (is (str/includes? (str prob) "STAGE CRASHED")) (is (str/includes? (str prob) "boom in the judge"))))) +(deftest a-run-that-died-reaches-the-supervisor-with-its-stack + ;; A failed run is resumable, and the resumed run's stream is the one role + ;; that can edit a cell. It gets the run's :run-error beside the stages' + ;; crashes, with the frames, so it can tell whose fault it is and where — + ;; a cell frame makes the layer userspace, which the digest says outright. + (let [conn (db/open! ":memory:") + rid (runs/start-run! conn {:problem "p"})] + (journal/note! conn rid :run-error + {:data {:error "boom in spawn" :node ":spawn" + :trace ["samizdat.agent.beam/spawn-children! (beam.clj:272)" + "cells.beam/fn--612 (beam.clj:600)"]}}) + (let [{:keys [gather prob]} (reasoning-over conn rid)] + (is (true? (:oversight/worth-a-look? gather))) + (is (= 1 (count (:oversight/crashes gather)))) + (is (str/includes? (str prob) "boom in spawn")) + (is (str/includes? (str prob) "samizdat.agent.beam/spawn-children! (beam.clj:272)") + "the frames, not just the message") + (is (str/includes? (str prob) "layer: userspace"))))) + (deftest the-loops-soft-cap-is-the-supervisors-to-decide ;; The feature loop's route note carries the revision and the soft cap. At ;; the cap the loop keeps solving on its own ladder, and the supervisor is diff --git a/test/samizdat/proc_test.clj b/test/samizdat/proc_test.clj index 47034528..0b0e05c7 100644 --- a/test/samizdat/proc_test.clj +++ b/test/samizdat/proc_test.clj @@ -21,7 +21,11 @@ [clojure.test :refer [deftest is]] [samizdat.engine.proc :as proc])) -(def ^:private needle "sleep 987") +;; Unique to this test process. The sweep is `pkill -f` on it, so a fixed +;; name let two suites on one machine — two worktrees, CI beside a dev run — +;; kill each other's child mid-test: the victim's `wait` returned, the run +;; ended before its timeout, and the other side found a survivor. +(def ^:private needle (str "sleep " (+ 900000 (rand-int 99999)))) (defn- survivors [] ;; pgrep exits 1 when nothing matches — an empty list, not an error. @@ -43,8 +47,8 @@ (sweep!) (try (let [r (proc/run {:timeout-ms 1500} "sh" "-c" - "trap '' TERM; sleep 987 & wait")] - (is (:timeout r) "the run times out") + (str "trap '' TERM; " needle " & wait"))] + (is (:timeout r) (str "the run times out; it returned " (pr-str r))) (Thread/sleep 300) (is (empty? (survivors)) "a TERM-trapping child tree must not outlive the reap")) diff --git a/test/samizdat/server_test.clj b/test/samizdat/server_test.clj index 7b1bd06b..3defd2a6 100644 --- a/test/samizdat/server_test.clj +++ b/test/samizdat/server_test.clj @@ -25,13 +25,65 @@ [ring-chez.http] [ring-chez.adapter :as adapter] [ring-chez.socket :as socket] + ;; the java.time.* host shim, before data.json + [jolt.time] + [clojure.data.json :as json] [samizdat.agent.gitdiff :as gitdiff] + [samizdat.api.runs :as api-runs] + [samizdat.approval :as approval] [samizdat.api.control :as control] [samizdat.server :as server] [samizdat.store.db :as db] [samizdat.system :as system] [samizdat.userspace :as userspace])) +(defn- call + "The handler's answer to `method` `uri`, with the JSON body read back." + [method uri] + (let [r (server/handler {:request-method method :uri uri})] + (assoc r :json (some-> (:body r) (json/read-str :key-fn keyword))))) + +(deftest a-route-binds-its-path-parameters + (with-redefs [system/conn (constantly nil) + system/config (constantly {}) + api-runs/get-run (fn [_ id _] {:run {:id id}}) + api-runs/turn-detail (fn [_ id branch turn] {:id id :branch branch :turn turn})] + (is (= "r1" (get-in (call :get "/v1/runs/r1") [:json :run :id]))) + (is (= {:id "r1" :branch "B2" :turn 7} + (:json (call :get "/v1/runs/r1/branches/B2/turns/7"))) + "every segment, the deepest route included") + (is (= "r1" (get-in (call :get "/v1/runs/r1/") [:json :run :id])) + "a trailing slash is the same resource"))) + +(deftest a-literal-route-wins-over-a-parameter-at-the-same-depth + ;; /v1/approvals and /v1/runs/:id/approvals must not be confused, and + ;; /v1/events is its own route, not a run id. + (with-redefs [approval/pending (fn [run-id] [{:run run-id}])] + (is (= [{:run nil}] (:approvals (:json (call :get "/v1/approvals"))))) + (is (= [{:run "r9"}] (:approvals (:json (call :get "/v1/runs/r9/approvals"))))))) + +(deftest an-unknown-route-is-a-json-404-naming-what-was-asked + (let [r (call :get "/v1/nope")] + (is (= 404 (:status r))) + (is (= "not_found" (get-in r [:json :error :type]))) + (is (= "Not found: GET /v1/nope" (get-in r [:json :error :message])))) + (testing "and so is a known path under the wrong method" + (let [r (call :delete "/v1/runs")] + (is (= 404 (:status r))) + (is (= "Not found: DELETE /v1/runs" (get-in r [:json :error :message])))))) + +(deftest a-handler-that-throws-is-a-json-500 + (with-redefs [system/conn (fn [] (throw (ex-info "no database" {})))] + (let [r (call :get "/v1/runs")] + (is (= 500 (:status r))) + (is (= "no database" (get-in r [:json :error :message])))))) + +(deftest a-redefined-handler-answers-the-next-request + ;; The table names handlers by var so a REPL redefinition lands without + ;; restarting the server. The router must call through the var. + (with-redefs [server/health (fn [_] (server/json-response {:status "redefined"}))] + (is (= "redefined" (get-in (call :get "/health") [:json :status]))))) + (deftest slow-clamps-its-sleep ;; /slow exists so the smoke probe can prove /health still answers while a ;; handler is busy; its ms parameter is a dial for "briefly busy", not a @@ -99,8 +151,15 @@ (is (= [1 2 3] [(:staged b) (:unstaged b) (:untracked b)])) (is (= "did a thing" (:last_commit b))) (is (= "glm" (:provider b)) "a string on the wire, not a keyword") + (is (nil? (:provider_name b)) "no alias declared, none claimed") (is (= "glm-5.3" (:model b))) (is (= 128000 (:context_window b))))) + (testing "the alias config.edn declared, which is what /model takes" + (with-redefs [system/config (fn [] {:run {:root "/tmp/p"} + :llm {:provider :local :provider-name :bonsai + :model "local-model"}}) + server/cached-snapshot (fn [_] nil)] + (is (= "bonsai" (:provider_name (server/project-body)))))) (testing "outside a git tree it still names the project" (with-redefs [system/config (fn [] {:run {:root "/tmp/plain"} :llm {:provider :local :model "m"}}) diff --git a/test/samizdat/tui_readmodel_test.clj b/test/samizdat/tui_readmodel_test.clj index 3975ec2d..d3bb606f 100644 --- a/test/samizdat/tui_readmodel_test.clj +++ b/test/samizdat/tui_readmodel_test.clj @@ -25,6 +25,7 @@ every other panel work identically on a live run and a finished one." (:require [clojure.test :refer [deftest testing is]] [db.jdbc] + [samizdat.agent.live :as live] [samizdat.api.runs :as api-runs] [samizdat.store.db :as db] [samizdat.store.journal :as journal] @@ -50,6 +51,38 @@ (is (= 2 (count board))) (is (= #{"wire the panel" "and close it"} (set (map :title board)))))))) +(def ^:private cfg + "A server whose default is a local model and which also declares a hosted + one: the endless-flight arrangement, where a run started on DeepSeek was + captioned with the server's model and gauged against its 32k window." + {:llm {:provider :local :provider-name :bonsai :model "local-model" + :context-window 32768} + :providers {:bonsai {:type :local} + :deepseek-flash {:type :deepseek :model "deepseek-v4-flash"}}}) + +(deftest the-run-detail-says-which-model-the-run-is-on + (with-db [c] + (let [rid (runs/start-run! c {:problem "p" :provider "deepseek-flash" + :model "deepseek-v4-flash"})] + (try + (is (= {:provider "deepseek-flash" :model "deepseek-v4-flash" :context_window 128000} + (:llm (:run (api-runs/get-run c rid cfg)))) + "the run's own provider and ITS window, not the server's default") + (testing "and a live switch, which the row never learns about" + (live/set! rid nil {:model "deepseek-v4-pro"}) + (is (= "deepseek-v4-pro" (get-in (api-runs/get-run c rid cfg) [:run :llm :model]))) + (live/set! rid nil {:provider :bonsai}) + (is (= {:provider "bonsai" :model "local-model" :context_window 32768} + (select-keys (get-in (api-runs/get-run c rid cfg) [:run :llm]) + [:provider :model :context_window])) + "a provider switch is the default's own config, probe and all")) + (finally (live/forget-run! rid))))) + (testing "a provider nothing declares any more still names what the row says" + (with-db [c] + (let [rid (runs/start-run! c {:problem "p" :provider "gone" :model "m1"})] + (is (= {:provider "gone" :model "m1"} + (get-in (api-runs/get-run c rid cfg) [:run :llm]))))))) + (deftest a-closed-task-leaves-the-board ;; The panel shows what is being worked on. A board that accumulated every ;; finished task would be a log, and there is already one of those. diff --git a/test/samizdat/tui_state_test.clj b/test/samizdat/tui_state_test.clj index f45d1345..e137dad6 100644 --- a/test/samizdat/tui_state_test.clj +++ b/test/samizdat/tui_state_test.clj @@ -80,6 +80,34 @@ (is (true? (:connected? s))) (is (nil? (:error s))))) +(deftest a-good-poll-does-not-erase-what-an-action-was-told + ;; A refused start set its reason and then forced a poll; the poll's + ;; `connected` cleared :error before a frame drew it, so "starting…" + ;; blinked and nothing followed. Only an error the connection raised is + ;; the connection's to clear. + (let [refused (-> (st/initial "b") + (st/set-input "add fog") + (st/apply-start {:ok false :error "HTTP 503: model endpoint not answering"}))] + (doseq [[what poll] [[:steps #(st/apply-steps % {:ok true :body {:steps [] :next 0}})] + [:runs #(st/apply-runs % {:ok true :body {:runs []}})] + [:detail #(st/apply-detail % {:ok true :body {:run {:status "failed"}}})] + [:branch #(st/apply-branch % {:ok true :body {:turns []}})] + [:approvals #(st/apply-approvals % {:ok true :body {:approvals []}})]]] + (is (re-find #"503" (str (:error (poll refused)))) (str what " kept the refusal")))) + (testing "while an outage is still cleared by the poll that gets through" + (let [s (-> (st/initial "b") + (st/note-error "HTTP 409: refused") + (st/apply-runs {:ok false :error "connection refused"}) + (st/apply-runs {:ok true :body {:runs []}}))] + (is (nil? (:error s)))))) + +(deftest a-refused-start-says-why-in-full + ;; The status strip clips at 40 columns, which cut the reason off at the + ;; endpoint's URL. The whole of it goes into the conversation. + (let [s (st/apply-start (st/initial "b") + {:ok false :error "HTTP 503: model endpoint http://127.0.0.1:8080/v1 - connection refused"})] + (is (some #(re-find #"connection refused" (:text %)) (:local-notes s))))) + (deftest selecting-a-run-resets-everything-that-belonged-to-the-last-one ;; The bug this exists to prevent: cursors and traces carried across a run ;; change, so the new run's panel opened showing the old run's steps and diff --git a/test/samizdat/tui_widgets_test.clj b/test/samizdat/tui_widgets_test.clj index c8ca51f3..9b6ab697 100644 --- a/test/samizdat/tui_widgets_test.clj +++ b/test/samizdat/tui_widgets_test.clj @@ -331,6 +331,16 @@ (is (str/includes? said "forced 19") "and why") (is (str/includes? said "1240") "and what the harness's own block adds per turn") (is (>= (count (nodes-of :gauge out)) 3) "the fill is a gauge like the other two")) + (testing "against the RUN's window when the server resolved one" + ;; A DeepSeek run on a server whose default is a 32k local model read + ;; 12k / 32k (38%) when it was 12k / 128k. + (let [said (texts (render :widget/context + {:project {:context_window 32768} :branch-id "B1" + :detail {:run {:llm {:context_window 128000} + :usage {:total-tokens 1 :turns 1}} + :branches [{:id "B1" :context {:turn 1 :prompt-tokens 12800}}]}} + {:title "CONTEXT"}))] + (is (re-find #"12k */ *128k" said)))) (testing "no measured branch, no window: the fill line is simply absent" (let [said (texts (render :widget/context {:detail {:run {:usage {:total-tokens 4000 :turns 4} @@ -500,6 +510,38 @@ (is (str/includes? said "running") "and what the run is doing") (is (str/includes? said "61aba012") "the run, short"))) +(deftest the-footer-names-the-model-that-will-answer + ;; It read (or (:model run) (:model project)): the model a run STARTED on, + ;; kept on screen after it ended, so a failed DeepSeek run captioned a + ;; server that would start the next one on a local model — and a start + ;; refused because that local model was down read as a DeepSeek problem. + (let [p {:project "ef" :provider "local" :provider_name "bonsai" + :model "local-model" :context_window 32768} + ended {:connected? true :run-id "r1" :project p + :detail {:run {:status "failed" :model "deepseek-v4-flash" + :llm {:provider "deepseek-flash" :model "deepseek-v4-flash" + :context_window 128000}}}} + running (assoc-in ended [:detail :run :status] "running")] + (let [said (texts (render :widget/status ended {}))] + (is (str/includes? said "bonsai:local-model") "what a start from here would use") + (is (not (str/includes? said "deepseek")) "not what the ended run used")) + (is (str/includes? (texts (render :widget/status (assoc ended :next-llm {:model "glm"}) {})) + "glm") + "a /model kept for the next run is the next run's model") + (let [said (texts (render :widget/status running {}))] + (is (str/includes? said "deepseek-flash:deepseek-v4-flash") + "a running run: the model it is on now, live switch included")) + (testing "and the gauge is against the run's window, not the server's" + (let [said (texts (render :widget/status + (-> running + (assoc :branch-id "B1") + (assoc-in [:detail :branches] + [{:id "B1" :status "active" + :context {:turn 3 :prompt-tokens 12800}}])) + {}))] + (is (re-find #"/ *128k" said)) + (is (not (re-find #"/ *32k" said))))))) + (deftest the-footer-draws-before-anything-has-answered ;; The first frame: no project, no run, offline. Every segment is optional ;; and the strip still has to be a strip. diff --git a/tui/samizdat/tui/state.clj b/tui/samizdat/tui/state.clj index 02a24835..b55418c0 100644 --- a/tui/samizdat/tui/state.clj +++ b/tui/samizdat/tui/state.clj @@ -52,6 +52,9 @@ {:base base :connected? false :error nil + ;; Whether :error is an outage, which the next poll that gets through + ;; clears, rather than an action's refusal, which it must not. + :error-outage? false ;; Beside :error rather than sharing it: the status line paints an error ;; red, and "starting…" is not a failure. :notice nil @@ -97,14 +100,21 @@ ;; --- folding what the pollers fetch ----------------------------------------- -(defn- connected [s] - (assoc s :connected? true :error nil)) +(defn- connected + "A poll got through. It clears the error only when the error was an outage: + one an action was answered with — a refused start, abort or steer — is not + the poll's to take back. Clearing every error here wiped a refused start + before a frame drew it, since start! polls straight after, so \"starting…\" + blinked and nothing followed." + [s] + (cond-> (assoc s :connected? true) + (:error-outage? s) (assoc :error nil :error-outage? false))) (defn- disconnected [s {:keys [error]}] ;; Cursors and everything already drawn survive: the point of a cursor is ;; that an outage costs nothing, and a panel that blanked on a dropped ;; connection would lose the history that says what happened before it. - (assoc s :connected? false :error (or error "no server"))) + (assoc s :connected? false :error (or error "no server") :error-outage? true)) (defn apply-steps "Fold a steps tail into the trace. @@ -389,7 +399,7 @@ \"starting…\" beside \"HTTP 503\" tells the reader nothing about which happened." [s msg] - (cond-> (assoc s :error (when (not-empty (str msg)) (str msg))) + (cond-> (assoc s :error (when (not-empty (str msg)) (str msg)) :error-outage? false) (not-empty (str msg)) (assoc :notice nil))) (defn note-notice @@ -426,6 +436,8 @@ (let [status (some-> (get-in s [:detail :run :status]) str)] (if (and (:run-id s) (or (nil? status) (= "running" status))) :submit :start))) +(declare note-local) + (defn apply-start "Fold the answer to POST /v1/runs. @@ -445,8 +457,11 @@ ;; having failed. (assoc :error nil) (note-notice (str "started " (str id)))) - (note-error s (or (not-empty (str error)) - "the server accepted the request and returned no run id"))))) + ;; Into the conversation as well: the status strip clips at 40 + ;; columns, which cut an endpoint refusal off at its URL. + (let [why (or (not-empty (str error)) + "the server accepted the request and returned no run id")] + (-> s (note-error why) (note-local [(str "run not started: " why)])))))) ;; --- the pushed event stream ------------------------------------------------- diff --git a/tui/samizdat/tui/widgets.clj b/tui/samizdat/tui/widgets.clj index be53b546..a0d7145d 100644 --- a/tui/samizdat/tui/widgets.clj +++ b/tui/samizdat/tui/widgets.clj @@ -432,7 +432,8 @@ ;; last request against the model's context window, and the run's ;; hit rate with the turns that missed, by cause. Each line draws ;; only when its number was measured — nil is unknown, not zero. - window (get-in state [:project :context_window]) + window (or (get-in run [:llm :context_window]) + (get-in state [:project :context_window])) fill (branch-fill state) rate (get-in run [:usage :cache-hit-rate]) misses (get-in run [:usage :cache-misses]) @@ -765,6 +766,27 @@ (assoc row 1 {:flex true :height rows})] :else row))) +(defn- model-label [{:keys [provider model]}] + (cond (and provider model) (str provider ":" model) + :else (or model provider))) + +(defn- footer-model + "The model that answers from here: while the run on screen runs, the one + it is on now (the server's :llm on the run, a live switch included); + otherwise the one a start would use — a /model kept for the next run, else + the server's default. The run row's :model is where the run STARTED, and + captioning an ended run's model beside a server that would start the next + one elsewhere is how a refusal from a local model that was down read as a + DeepSeek problem." + [state] + (let [run (get-in state [:detail :run]) + p (:project state)] + (if (and run (= "running" (str (:status run)))) + (or (model-label (:llm run)) (:model run)) + (or (get-in state [:next-llm :model]) + (model-label {:provider (or (:provider_name p) (:provider p)) + :model (:model p)}))))) + (defn status "The footer: where the harness is pointed, what is answering, what the run has spent, and what it is doing. @@ -784,12 +806,15 @@ [state props] (let [run (get-in state [:detail :run]) p (:project state) - model (or (:model run) (:model p)) + model (footer-model state) turns (get-in run [:usage :turns]) ;; The branch's last request, not the run's total (karamazov-pdes, ;; see branch-fill); no measured request, no segment. fill (when-let [used (branch-fill state)] - (fill-segment used (:context_window p))) + ;; The run's window: a run on another provider than the + ;; server's default was gauged against the default's. + (fill-segment used (or (get-in run [:llm :context_window]) + (:context_window p)))) sep [:text {:class :dim} " \u2502 "]] (into [:hbox {:class :status}] (remove nil? @@ -799,7 +824,7 @@ (when-let [l (project-label p)] [:text {:bold true} (str " " (clip l 34))]) (when model sep) - (when model [:text {:class :model} (clip (str model) 20)]) + (when model [:text {:class :model} (clip (str model) 34)]) (when fill sep) (when fill [:text {:class :dim} fill]) (when turns sep)