Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion deps.edn
Original file line number Diff line number Diff line change
@@ -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"]

Expand Down Expand Up @@ -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"
Expand Down
36 changes: 15 additions & 21 deletions docs/RFCS/RFC-013-cancellation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*:
Expand Down Expand Up @@ -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

Expand Down
10 changes: 0 additions & 10 deletions resources/cells/decompose.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
20 changes: 15 additions & 5 deletions resources/cells/oversight.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions resources/gates.edn
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
9 changes: 4 additions & 5 deletions resources/manual.edn
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."}
Expand Down
2 changes: 1 addition & 1 deletion resources/prompts/system-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
41 changes: 21 additions & 20 deletions src/samizdat/agent/beam.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}))))
Expand Down Expand Up @@ -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!
Expand Down
Loading
Loading