Skip to content

weft v0.16.0

Latest

Choose a tag to compare

@wajihtba wajihtba released this 14 Oct 13:58
· 1 commit to main since this release

Added

  • core, thread, runtime, obsdb, Studio: approvals linked (U13.1; UX-E-17). core.ResumeOf(runID), a new run option, names the parked run a resume continues: its invoke_agent span and run_start record carry weft.resumed_from, and every run fed decisions carries weft.resume.calls — each parked call as <call id>=approved|denied|resolved|no_decision, in call order. A call the resume denies (Deny, or no decision) gets a zero-length execute_tool span marked weft.tool.denied / weft.tool.denied_kind (error.type DENIED, never the reason); the root's weft.run.denied still counts it and the span is not counted again. The decider rides as metadata weft.approval.actor. The core cannot infer the link (a transcript carries no run id): thread passes it on every resume turn with the decisions' actor (a signed decision's key id, else Who; "" on every other turn), weft/runtime on the playground's resume with the command's actor (panel:<public id>, server, local); an app resuming a plain Generate passes weft.ResumeOf(res.ID). obsdb derives RunRow.ResumedFrom (SQLite migration 0018's runs.resumed_from, ClickHouse 0019's weft_runs.ResumedFrom, restating both run views with the four new contract keys) and RunRow.ResumedBy on the parked run — read time on both backends, so a resumer ingested before or after its parked run links it; Studio's run row and document serve resumed_from / resumed_by (omitted when none), the seam U1's status word reads. Model-visible behaviour, the transcript and the approval boundary are unchanged. Contract fixtures: approval_denied (now a Deny and an undecided call, with ResumeOf) and resumed (rw.support-desk's shape: Approve + Resolve + ResumeOf + the actor). Since U13.1-fix: resumed_by is the latest resumer by start time (a tie: the larger id), never the largest id; weft.resume.calls is capped at 32 calls (,…+k beyond); thread links any run that applies recorded decisions, and appends each step's messages before its own reporting, so a panicking tracer no longer delays a step's durable append to the turn's end; weft.approval.actor is metadata and survives content-off — put an opaque id in Decision.Who / KeyID, never an email.

  • core: what stopped a run is on its record (U3.1; UX-E-11, UX-A-08, UX-S1-02). The invoke_agent span carries weft.run.stopped_by and the run_finish event stopped_by (RunFinish.StoppedBy): the fired StopWhen condition's manifest name (has_tool_call:submit, step_count_is:2, output_submitted, a custom condition's String(), else custom), final_answer, parked, or — on the span alone, a failed run having no run_finish — max_steps, usage_limit, loop_detected, model_retries_exceeded, canceled. ADR 0016 amendment 2026-10-12; docs/record-contract.md §3/§4; contract cases stopped_by and canceled.

  • core: CancelCause, a typed cancellation cause (UX-E-41): a run canceled through context.WithCancelCause with a cause that has a Kind() string records it as weft.cancel.cause on its invoke_agent span (deadline for a deadline). thread fells a turn with thread.interrupted (an Interrupt/Rollback send) or thread.closed (a Close that gave up); runtime's cancel command with studio.cancel, and its link's own shutdown (runtime.Install's shutdown func) the runs still in flight with app.shutdown. The run's error is unchanged (ctx.Err()).

  • obsdb, Studio, weft runs: what stopped a run is on its row (U3.2; UX-E-11, UX-E-41, UX-A-08, UX-S1-02). RunRow.StoppedBy / CancelCause, columns stopped_by / cancel_cause derived at ingest from the invoke_agent span and run_finish (the larger when they differ) and backfilled (SQLite migration 0010, ClickHouse 0011, which restates both run views); RunQuery.StoppedBy and ?stopped_by= on /api/runs and /api/search (exact, or a prefix ending in : — has_tool_call:); the run row and run document carry stopped_by / cancel_cause (omitted when empty); weft runs prints a STOPPED BY column and --json carries both. Studio's run header shows a "stopped by …" chip on every status, linking the step where it fired, with the story's end card mirroring it; the stop reason reads "last step stopped on" only where it contradicts; "ended without run_finish" only when neither a terminal event nor a stopped_by exists and the run is not failed (a failed run that named no stop — a model error — ends on "■ failed · ", its Error root span the terminal record); the chip's detail is the error's own (the usage limit's counts, a loop's "N identical steps in a row", a retries breach's tool "x" retried N times; a cancel shows its cause only); the runs table's line is skipped for the default exit (a succeeded final_answer); the panel's turn chips carry the same words (src/lib/stopped-by.ts). Panel +502 B when it landed (the ledger's U3.2 row; 78,912 B after U3-fix and U2-fix). Record contract §6.

  • thread, obsdb, Studio, panel, weft runs: titles, not ids (U6; UX-A-11, UX-S5a-09, UX-B-17, UX-D-10, UX-A-25, UX-B-25). thread stamps the session's title (Session.Title, set through SetInfo) on every later turn as run metadata weft.session.title — a label, so it survives a content-off destination. obsdb.SessionRow.Title (the newest turn's non-empty title) and FirstPrompt (the first turn's prompt preview), obsdb.SessionTitleKey, and SessionQuery.Q (every term in the title, the first turn's whole prompt, the id, public id or agent; both backends, no migration); /api/sessions rows and documents carry title / first_prompt and take ?q= (the sessions page's text box is now server-side). Studio leads every run row, session turn ("t3" + prompt, "resume of t1 · 1 decided call" for a resume — the previous turn parked and this one carries no new prompt), session row, ⌘K item, run page heading ("agent · prompt") and browser tab (director · How many units… · succeeded · weft studio; a session Acme support desk · 9 turns) with its title — src/lib/title.ts's runTitle / sessionTitle: the prompt, else the agent, else "(content not captured)" for a content-off run, else the id — the id a copyable mono line under it. The panel's turn row reads "t3 ", its narrow picker "● parked t3 How many units… · 2 steps", ids in the title; its filter reads each row's prompt_preview (the per-turn prompt memo is gone). weft runs prints a PROMPT column (40 runes, "…") before STOPPED BY. Record contract §2/§6; contract case session_title, and the fixtures' sessions. Panel +323 B (79,347 B).

  • core, otel, obsdb, Studio, weft doctor: emitted counts and drop accounting (U5; UX-E-04, UX-E-05, UX-E-38, UX-A-41). The invoke_agent span carries weft.run.events and weft.run.messages at its end — the durable event records and growth messages records the run handed to its LoggerProvider, 0 with no logger. obsdb derives events_emitted / messages_emitted on the run row (RunRow.EventsEmitted / MessagesEmitted; SQLite migration 0012, ClickHouse 0013 restating the traces view; both backfilled) and the readers count the loss: fewer events stored than emitted is gap, cause events_lost — "N of M events were lost", split into positions missing below the last stored one and "the last K events were lost" (obsdb.EventsLostHole, EventsLostReason; through TailLostHole, any status); a root that emitted records none of which was stored reads the same gap ("every one"), and not_recorded/no_logger is reserved for a root that emitted none. tail_lost stays for roots without the count. The cause of the stress runs' silent loss, proven by otel.TestSDKBatchQueueDropsOldestSilently: the SDK's log BatchProcessor overwrites its oldest queued record when full (run_start first) and tells nobody. Each batch destination now admits its records ahead of the SDK queue (otel/admission.go): new otel.QueueSize(n) (Studio's default 16,384, others 2,048) and dev-only otel.Blocking() (refused outside WEFT_ENV=dev with one WARN, like RawBodies); a full queue sheds deltas first, then ordinary records, run_start/run_finish/the root span/heartbeats last; every refusal is counted, warned once per destination ("weft/otel: destination queue full", naming the destination and the knob) and carried as weft.records.dropped on that destination's heartbeats. Studio keeps the largest value per service (/api/meta's ingest.dropped, the Settings page's "ingest health", weft doctor's ingest line); the run row serves events_emitted / messages_emitted; every gap's fix names the knob. The Local destination writes synchronously and loses nothing (TestLocalFloodLosesNothing). Record contract §3/§4/§6/§7; contract fixtures carry the counts (tracer_only: emitted 0 → no_logger; tail_lost: 4 of 8 → events_lost).

  • core, mcp, obsdb, Studio, panel: error kinds and problem counters (U4; UX-S1-01, UX-E-26, UX-E-12, UX-E-13, UX-S2-05, UX-A-40). The execute_tool span types a tool failure — error.type panic with weft.panic.layer handler | middleware (was tool_error), mcp_transport / mcp_tool_error from weft/mcp (through an error's ErrorType() string, a convention core reads after the context errors), weft.tool.input_error strict | schema | syntax beside INVALID_INPUT — and weft.tool.truncated_bytes when the result cap cut (on the tool_finish record too); the invoke_agent span counts a resume's denied calls in weft.run.denied. The model-visible text is unchanged. The run row gains error_type (the root's error.type, the context errors read as cancelled) and problem counters derived at ingest from the spans as a de-duplicated token set — attempts_retried, fallbacks, tool_errors, denied, truncated, repaired (always 0 in this release: the core does not report a repair yet, U23.1); the run document adds log_warn / log_error, counted at read (SQLite 0013, ClickHouse 0015 restating both run views, both backfilled). ?error_type= on /api/runs and /api/search. Studio's runs table badges problems only (↻2, ⇄, ✋, ✗, ✂, [max_steps]), the run header adds the kind chip (a cancelled tone) and the problems beside the step count, the span drawer names a tool's failure kind and cut; the panel's turn row draws the same chips (lib/error-kind.ts, +806 B). Contract cases tool_problems, retry_fallback, approval_denied.

  • core, obsdb, Studio, panel: phase 7 polish, batch 2 (U32.2; UX-E-64, UX-S1-07, UX-S1-08, UX-S1-09, UX-S1-10, UX-S1-12, UX-S2-14, UX-S2-15, UX-S4-04, UX-S4-14). SQLite migration 0019 adds spans_start (spans by start_ns, trace_id): the traces list's since window narrows to the traces with a span in it before grouping, instead of scanning every span (ClickHouse already prunes it by its toDate(Timestamp) partitions: no migration). A loop_detected run's repeated step cards carry "repeats step K", linking the first of the N steps DetectLoops counted (the error's count, ending at the step it failed at). A tool result whose text is Repair's synthesised no result recorded: the call was interrupted is badged "synthesised by Repair" on every request message line — the one surface that can see it: Repair writes the text into a later run's input transcript only, so no tool_finish carries it and the story's pills and the panel's call rows never badge it (P7-fix2 dropped those two paths). Matched on the text: the record carries no other signal; recording weft.messages.repaired is not done. A failed model attempt reads a phrase for its error.type (stream_idle "no bytes from the provider within the adapter's idle timeout", model_requests_denied, model_error, the context errors, model_contract; an unknown type as itself, "no description"), the raw type in the hover. The run header's public id and metadata chips link to the runs list filtered by them (?public_id=, ?tag=k=v). Settings names how app logs reach a run (the LoggerProvider; slog needs a bridge — a slog→OTel bridge (otel.Slog) is planned; an out-of-loop Agent.CallTool records nothing — also in its godoc), and the ingest-health line names the run_start and run_finish carriers. docs/record-contract.md §6 lists what the record attributes to user code today (effects only; the hooks are U23's and U25's). The metadata_cap reason names its three limits ("limit 64 keys, 128-byte keys, 1 KiB values"). Not changed, accepted: a server-side parked status filter (the client-side pick and the session's state cover it); a live PUT /api/settings/pricing (download + restart); ingest health persisted across a restart.

  • Studio, panel: one unpriced-cost reason per page (U7; UX-A-12, UX-D-21, UX-S4-07, UX-H-33, UX-B-27, UX-C-17). A scripted model no pricing row matches — every wefttest model, the playground's weft/runtime scripted engine — is an exact $0 by a rule in Go (cost_known true, cache exact, new cost_badge value scripted), drawn "$0 · scripted"; a row that matches still wins. A pricing row with no provider (or "*") matches on its model glob alone, any transport (an OpenAI-compatible model behind the openai adapter); a file's missing provider loads as "*" instead of failing. A summed figure's reason (delegated, session, experiment) is one line counting its runs — "2 of 3 subagent runs unpriced: no price for openai/x" — never a clause per run; an unpriced run's reads "openai/x has no price". New unpriced_models / delegated_unpriced_models list the models behind a figure; Studio's runs list, sessions list and run header, and the panel's header, say "N runs unpriced: no row for …" once, beside a "price this model…" link to Settings (/settings?price=, a prefilled row). An unpriced cell is "—" with a small "unpriced" chip (the sessions list's a number or "—"); a partial sum reads "≥ $x · k of n runs priced", "—" when its known part is $0 (never "≥ $0.00", never "<" under "≥"). The live PUT /api/settings/pricing was not added: the table is still applied by download and restart.

  • obsdb, Studio: manifests stored by hash, and indexes for the run lists (U27; UX-E-16, UX-E-23, UX-E-35, UX-E-36). New optional obsdb.ManifestStore (PutManifest, Manifest; StoredManifest, ManifestHash, CheckStoredManifest, ErrInvalidManifest), implemented by both backends (SQLite migration 0014, ClickHouse 0016): each agent's one-agent core.Manifest document under its sha256 — the weft.manifest.hash its runs carry — content-addressed, the first write kept, kept forever (DeleteRun, DeleteSession, Sweep and Vacuum never touch it). Studio writes it from the weft.json it serves (at New) and from every runtime registration, and serves GET /api/manifests/{hash} (capability manifests; {manifest_hash, agent, source, first_seen, current_manifest_hash, manifest}; a read-scoped panel token 403 hidden, as /api/manifest); the run document carries manifest_available: true when its hash resolves. SQLite: the public-id, session and session-list predicates state their partial index's <> '' so the planner uses runs_public / runs_session (each session's newest-turn, title, prompt and content-off subqueries walked every top-level run before); runs_public is rebuilt over the list's whole key, runs_session_started is new, runs_started and runs_experiment gain the run_id tie-break; runs_all serves all=1, runs_last_seen the live backfill window and the running filter, partial runs_failed the failed filter (runWhere adds each status's implied condition beside the CASE); migration 0015 adds partial runs_failed_all for the all-runs failed page (status=failed&all=1, which walked runs_all until the page filled), and the all-runs running page and the live backfill write their order +started_ns so runs_last_seen finds the few matches instead of runs_all walking every row (U27-fix). Cost of the upgrade: migration 0014 rebuilds runs_public, runs_started and runs_experiment and builds four new indexes (runs_session_started, runs_all, runs_last_seen, runs_failed; 0015 a fifth, runs_failed_all) — a one-time synchronous rebuild inside Open, seconds on a large file before Studio serves; and runs_last_seen is rewritten on every last_seen bump, which is every record batch a run writes — one more index entry per batch. ClickHouse: a run's spans are read by TraceId = ? AND RunId = ? when its row names the trace (falling back to RunId alone), and otel_traces gains the idx_weft_run_id bloom filter (materialized in the background). The run-header "config at run time" view is left to U26.

  • Studio, panel: one status vocabulary on both surfaces (U1; UX-A-06, UX-B-04, UX-D-16, UX-F-07, UX-S3-03). src/lib/status.ts words every run row — running · succeeded · failed · interrupted · parked · cancelled, each with a hint and a colour role — and the runs list, the run header ("parked · 1 call awaiting a decision"), a session's turns and its header (its newest turn's word), the live table, ⌘K, and the panel's turn list, narrow picker and turn filter all draw it. parked and cancelled are read-time words, never stored (record contract §7): cancelled is failed with error_type cancelled (U4's tone folded in), parked is succeeded with pending calls while no later turn of its thread session exists — then it reads "parked → t2", a link to the resume; a plain Generate resume has no link yet and reads parked. The panel's "parked" filter returns open parks only and gains interrupted and cancelled; its host run event keeps the plain words. src/lib/palette.ts's ROLE gives each status one colour on both surfaces: running is Studio's --status-run in the panel too (new --weft-status-run; was the interrupted mustard), interrupted the mustard (was the tool blue), parked Studio's new --status-parked (the panel's purple), the live dot the succeeded green (was the accent); the panel's chip gains Studio's dot. Panel +589 B (81,716 B).

  • core, otel, obsdb, Studio, panel: phase 7 polish, batch 1 (U32.1; UX-S2-10, UX-S2-11, UX-S4-09, UX-S4-15, UX-E-42, UX-E-48, UX-E-50, UX-E-51, UX-E-58). A RETRY the loop counts carries weft.tool.retry (the tool's consecutive count with it) and weft.tool.retry_budget (MaxModelRetries) on its execute_tool span, and the budget's error names both (tool "x" retried 3 times, past MaxModelRetries(2); the model-visible RETRY: … text is unchanged); Studio's story pill reads "retry 1/2", "retry 3/2 — budget spent", and "strict input" for a StrictInput refusal, from the call's span; model_retries_exceeded reads "tool retry budget spent". The invoke_agent span carries weft.tap.panics — the run's contained observer panics, beside Agent.TapPanics — counted as the run row's tap_panics problem ("⚠N tap panics"). The prompt record's body gains segments (instructions, then tool:<name> per snippet; content-gated, redacted per segment, dropped under a cap that cut the text), drawn under labels in Studio's Request pane (obsdb.PromptRecord.Segments, obsdb.PromptSegment; breaking for a caller comparing PromptRecord values with ==: the struct is no longer comparable). weft.metadata.dropped becomes the run document's truncated hole, cause metadata_cap ("N metadata pairs dropped (limit 64 keys / 1 KiB values)"). New problem tokens tap_panics: / metadata_dropped: (SQLite migration 0017 backfills the second; ClickHouse 0018 restates both run views with the three new keys in the meta tuple and backfills). The kill switch's kind chip reads "denied by the kill switch (WEFT_MODEL_REQUESTS)"; Settings › Token names weft mcp --url <this Studio> and docs/mcp-recipe.md; the run header shows the tool concurrency ("parallelism: 2" / "sequential") as the run ran: its weft.override.parallelism, else the request record's sequential_tools, else the run's stored manifest; the Wire tab is labelled "loop request → wire bytes".

Fixed

  • cmd/weft, obsdb, Studio, panel, weft doctor, docs: the pre-release review of the Go side (R7-prep). weft runs and weft doctor print every free-text cell — the prompt preview, agent names, stop causes, ids, Studio's error text, service names — with each control character (an escape sequence, a carriage return, C1 and bidi controls) as a space (internal/termsafe): a prompt or an OTLP-ingested record no longer drives the operator's terminal. A content-on run that lost some of its transcript batches badges gap, cause messages_lost — "K of N transcript batches were lost" (obsdb.MessagesLostHole, MessagesLostReason, obsdb.CauseMessagesLost; lib/honesty.ts), joined to an events_lost gap on the one badge; a store holding no messages record is never called lost (a content-off destination stores none; record contract §7 says what that cannot tell apart); contract case messages_lost, and tail_lost gains the cause. A failed root never settles: its row reads settling false while its event page reads done, one answer from the first read (DeriveSettling is passed rootEnded && !failed on both backends). weft doctor's ingest fix names the queue and failing exports ("queue full or exports failing — the app's WARN names which"). Record contract §10 lists session_title, and a test holds §10's rows to obsdb/testdata/contract both ways. Godoc examples for otel.QueueSize and otel.Blocking (its dev-only gate). Panel +41 B (82,373 B, measured on landing over P7-fix3).

  • obsdb, Studio, panel, docs: the phase 7 gate's dry run (P7-fix3). The run header's tool-errors entry names the failed calls' kinds from the run's loaded execute_tool spans, in lib/error-kind.ts's words — "✗3 3 tool errors: tool error ×1, timeout ×1, pending child ×1" (SUBAGENT_PENDING reads "pending child") — the count alone while the spans are not loaded, and "N not in the loaded spans" for calls the counter holds and the spans lack; the panel's turn chips stay counts. The panel's in-place edit fields (plan F2: the prompt, the insert boundaries, a reply, args, a result) take their visible text as their accessible name, "edit …" as the title (axe's label-content-name-mismatch); an empty field keeps the aria-label. The a11y budget gains the fields at rest with the rule checked by hand (jsdom leaves it incomplete). A records_dropped contract case pins weft.records.dropped on run_start / run_finish: out of meta, no row field (record contract §10). The concurrency hedge reads "…read when its spans are available" (a run whose spans never load was promised a read).

  • thread, obsdb, Studio, panel, docs: the review of P7-fix, U32.2 and U13.1-fix (P7-fix2). The run header's tool-concurrency chip names the manifest's value as that — "the manifest's value; the run's own setting is read once its spans load" — until the run's own invoke_agent span is read (a running run, or a token without the requests capability, never reads its weft.override.parallelism); once it is, "the run's manifest". The Repair badge is drawn on the request message lines only (see U32.2 below). A failed attempt's model_requests_denied reads the run chip's own words ("denied by the kill switch (WEFT_MODEL_REQUESTS)") and unsupported reads "the model cannot honour part of this request (ErrUnsupported)". The mw godoc rate-limit example pulls a past slot up to now, so idle time is never banked into a burst. A decided park whose resumer is a thread turn not in the loaded runs reads t<n> from its <session>-t<n> id instead of a cut that loses it. Comments only: the approval link's "any turn the decisions ride" path is marked defensive (today only resume turns carry decisions, ADR 0021 §1); SQLite 0018 / ClickHouse 0019 say the reader's rule (the latest resumer by start time, a tie: the larger id) — both runners track migrations by version, no content hash, and ClickHouse's skips -- lines, so editing them changes no applied database. The approval-actor fixtures use an opaque id (op_42), as contract §2 asks.

  • core, obsdb, Studio, panel: the combined review of U27-fix, U4-fix, U32.1 and U1-fix (P7-fix). weft.tool.retry is assigned after the step's tool batch, in call order, exactly as the guard counts — a RETRY in a batch that also holds a success of the same tool carries no mark (the count resets; it no longer reads "budget spent" on a run that goes on), and parallel RETRYs of one tool are numbered by call order, not completion order; the mark is on the execute_tool span only (its span ends after the batch, stamped with the call's own end), no longer on the tool_finish record, which is written before the batch is over. The run header's concurrency is the run's own (its weft.override.parallelism, then sequential_tools, then the manifest; "parallelism: 4" no longer shows for a run a Parallelism(1) run option made sequential). The metadata_cap hole's fix names all three limits ("64 keys, 128-byte keys, 1 KiB values"). ClickHouse 0018 has an upgrade test. The runs list under a loaded-rows filter (the parked pick, a duration) heads "N listed of M loaded", never the server's "matching" total; the parked chip says a resume that did not succeed is not looked for. SQLite's runOrder no longer applies under a public-id or session equality. The step route's attempts carry layer (weft.attempt.layer), and both renderers' attempts lists read "attempt 2 · fallback" (src/lib/attempts.ts's attemptLabel).

  • Studio, panel: the U1 review. "awaiting a decision" is said only where the resume was looked for — a session turn read against its session's whole turn list (the session page, the run header once its session loaded, the panel's session-scoped list); a plain Generate, a runs page under a filter or sort, the live table and an experiment's grid read "N calls pending · no resume recorded here" (a plain Generate: "a resume, if any, is not linked yet"), the word staying parked. statusOf reads a row's resumed_by first (U13.1's stored link, served on the run row). The runs list's status filter gains parked (succeeded asked of the API, the loaded rows kept where the word reads parked, its chip says so); the live table's filter matches status words on the key; the panel's filter gains "parked → (decided)". The experiment grid, a subagent's status, the flow strip's end mark and the live table's cancel toast and parked cell draw statusOf's word and role (a parked or cancelled run no longer reads succeeded / failed). The running word is drawn in a new --status-run-ink (#d13c10 light, 4.61:1 on paper; dark unchanged at 6.07:1) — the dot, pulse and pill border keep --status-run; the panel's theme test holds the word to 4.5:1 again. The [cancelled] kind chip is dropped where the status word already says cancelled. The panel's decided-park word selects its resume turn on click. Record contract §7 no longer spells a key no code writes (TestRecordContractDocNames passes) and says the next turn decided or dropped the calls. Panel +170 B (81,909 B).

  • core, obsdb, Studio, panel, weft runs: the U4 review. attempts_retried counts only a failed attempt a later attempt of the same model call followed (an exhausted three-try mw.Retry reads 2, a non-retryable failure 0; the words "model attempts retried"); fallbacks counts the attempts mw.Fallback marks — core.AttemptInfo gains Layer (core.AttemptLayerFallback), the attempt span weft.attempt.layer — never a model-name comparison; RETRY (ModelRetry, the repair loop) is no tool error (NO_SUCH_TOOL still is); the denied-approval token is keyed by its invoke_agent span, so two roots of one run id with the same count both count. The problem set is re-derived from the stored spans (SQLite 0016, ClickHouse 0017 restating the traces view and backfilling; U4's token kinds count nowhere). A result-cap cut is forgotten at its tool_finish with no logger wired (it stayed until run end), and a middleware that recovers a handler's panic and later panics reads weft.panic.layer middleware. The run document's log_warn / log_error come from one severity-narrowed scan (obsdb.DB.CountLogs, both backends) instead of up to ten paged reads, logs_capped marking floors ("≥" on both surfaces). Breaking for an obsdb.DB implemented outside the two shipped backends: the interface gains CountLogs (obsdb.CountOtherLogs is the shared body over a severity-narrowed LogCandidates). Record contract §3 says which denials weft.run.denied counts.

  • otel, obsdb, Studio, panel: the U5-fix and U7 review. A scripted parent whose subagents bill is no longer "$0 · scripted" where runs are summed (the panel's result pane and header, the experiment grid and totals, the playground): a run's whole bill must be $0 for the scripted words, as Go's set rule already required. A partial sum whose known part is under $0.0001 reads "> $0 · k of n priced" (the exact figure in its title), never a "≥ $0.0001" above the real figure; a paged sum whose loaded rows are all known at $0 reads "$0 · scripted" or "$0 · k of n priced", never "—". A session or experiment made only of the playground's scripted-engine runs (zero usage) is badged scripted, not a bare "$0.00". A session's or experiment's cost reason names no turn id ("1 of 3 turns' cost not known yet ()"; a delegated reason still names the child). The built-in $0 rule covers wefttest/* and weft/runtime + scripted only: weftarena/*, a wrapper that may wrap any model, is unpriced without a row of its own. A Go-built studio.Pricing row with no Provider is the any-provider row, as a file's missing provider loads. unpriced_models, delegated_unpriced_models and a reason's model list stop at five, a sixth and more one last element "(+n)". An unpriced run's reason is the model alone, "openai/x has no price". The ?price= prefill escapes *, ?, [ and \ in a model name, so the row prices that name alone. The span chain's Blocking latch now sets on the first timed-out wait (a context deadline of the same length fired first and skipped the latch, so a hung exporter cost a wait per span until the timer won), and the latch's first setting logs one WARN per destination: "destination : exporter stalled — records are refused until it recovers (Blocking)". ClickHouse: migration 0014 restates the logs run view with weft.records.dropped in its contract tuple, so stored weft_runs.Meta no longer keeps it (reads were already right); obsdb.NonMetaKeys lists MetaOf's exclusion set, and a test holds the ClickHouse copy to it whole.

  • otel, Studio, weft doctor: the U5 review. The span batch queue is built eight places above admission's size, so a ForceFlush marker under a full queue no longer makes the SDK drop an admitted span (likely the run's root) silently and leak its admission place for good. weft.records.dropped also rides each run's run_start and run_finish records (record contract §4), not only heartbeats of runs open 10 s, and Studio's ingest health reads it from them; the key is not caller metadata. otel.Blocking() is bounded per stall: after one timed-out wait the chain refuses at once (counted) until an export begins, and a span's End waits under its own 5 s deadline. A detached flush ends when its chain shuts down. /api/meta's ingest is the dev token's and setup A's loopback only (manifest_check's rule), omitted for a panel token. The ingest max stays per service: the pipeline's resource names no process instance.

  • panel: a thread resume across a gap in turn numbers (a queued turn's number dropped by ClearQueue) is titled "t5 resume of t3": the turn before is the highest loaded turn below, as Studio's session page reads it (U6 re-review c1).

  • obsdb, Studio, panel: the U6 review. The run page's heading of a content-off run of a named agent says the agent once (director · (content not captured), not director · director · …). ⌘K finds a session by its title or first prompt and a run by its prompt (cmdk keywords), the search capability or not. A session row carries content_off (obsdb.SessionRow.ContentOff, both backends, no migration: the first turn stored events and no messages record, not derived), so the sessions list titles an untitled content-off session "(content not captured)" instead of its id; an id-titled row no longer repeats the id under it. The panel's turn row and narrow picker title a thread resume with turnTitle as Studio's session page does ("resume of t3 · 1 decided call"); the picker adds the turn's duration and reads an experiment in its card's order (↳ replay of t3 · scripted · ● failed · 1 steps · 2.5s). Record contract §2 (weft.session.title across Fork, Branch and the compaction marker) and §6 (content_off); contract case session_title. Panel +218 B (79,565 B).

  • Studio: a settling run raises no counter gap on the server (its missing positions may still land) and none on the client (statusHoles reads isLive); a settling run's story foot reads "stream in progress…", never "the stream ended without run_finish"; a failed run's last step stays error under the counted gap (the U2 re-review's carried items).

  • Studio: the settling window reaches the UI (the U2 re-review). The run row and run document serve settling (omitted when false); the run page's polls and event tail, and the panel's turn, follow a settling run as a running one (isLive in src/lib/honesty.ts) and draw a settling note beside the status word, so a run_finish or a tail_lost gap landing after the root shows without a reload. A failed spans-only root's steps read error at once, never running; a settling step names no request gap in its Request and Messages-in panes; a run with a dropped mid-run batch and a lost tail says both reasons on its one gap badge. stopped_by: step_count_is: with no argument reads "stopped by a step count (StopWhen)". Record contract §6/§7.

  • obsdb, Studio: the U2 review (status from the root span). A succeeded root with events stored and no run_finish badges gap, cause tail_lost ("the run's root span ended but its run_finish record never arrived: a destination dropped the record tail, so the last steps' records are missing") on the run and on the step whose step_finish was dropped (that step reads ok, not error) — no longer a clean success (obsdb.TailLostHole, RunRow.FinishRecorded; fixture tail_lost's holes). A root stored before the records behind it settles for 30 s (RunRow.Settling, obsdb.DeriveSettling): no hole calls the records lost and the events route keeps done false while it does. EventPage.Done is true for a root-ended run once it stopped settling, so a poll that waits for it ends. A spans-only run's step documents and requests/tools panes read not_recorded, cause no_logger, not a step gap or an empty list (RunRow.RequestsHoleCause). weft_version comes from the run_start record and the root span alone (its attribute, else its resource), the larger string when they differ, on both backends — never another span's resource (ClickHouse migration 0012 restates the traces view). Derived runs all read root_ended (SQLite 0011, ClickHouse 0012). Record contract §6/§7.

  • obsdb: weft.run.stopped_by and weft.cancel.cause are contract keys, no longer caller metadata in the run row's meta (the U3.1 leak; on ClickHouse max(Meta) also lost them, failing TestContractClickHouse/tool_calls). SQLite's 0010 removes them from stored rows; ClickHouse's reader drops them through obsdb.MetaOf.

  • obsdb: a run whose invoke_agent span is stored is over — succeeded (or failed on an Error root) without a run_finish, never interrupted (UX-E-01). A tracer-only app's runs and runs whose record tail a destination dropped read the crash signal 30 s after they ended. New column root_ended, set at span ingest and backfilled from the stored spans (SQLite migration 0009, ClickHouse 0010; RunRow.RootEnded); the status filter, sessions and the sweep read the same rule. The run row's weft_version and manifest_hash now fill from the span (the version also from its resource) when no record names them (UX-E-02). Record contract §6 and §7, fixtures tracer_only, tracer_only_failed, tail_lost; ADR 0024 amended.

  • Studio: a weft run with spans and no stored record badges not_recorded, cause no_logger ("the app exported spans only", the fix naming weft.LoggerProvider / otel.Install) instead of derived, which is the stock-span run's badge alone (UX-E-03; obsdb.LostRecordsHole, obsdb.CauseNoLogger, lib/honesty.ts). Panel +103 B (78,201 B).

Changed

  • Devtools panel: the panel's size cap is 96 KiB (98,304 B), up from 80 KiB (audit decision 1); every delta still appends a ledger row. The ledger's cap (studio/web/panel-budget.json) is now the one source the build (vite.panel.config.ts) and scripts/panel-asset.ts fail on — the build still fails over it; one row, "A5.4 cap raised to 96 KiB", records the change at the then unchanged 78,098 B (20,206 B of headroom at the cap change; the ledger's rows since carry every delta).
  • Studio, panel: the pre-release review of the web side (W7-prep), UI words only. The problem badges (Studio's and the panel's turn chips) carry role="img" and their words as the accessible name. The problems chip draws no "parked" part for a decided park: its pending calls read "parked" only under the parked status word, "held" under any other. The resume fallback reads "next turn tN (decided or dropped)" — it no longer claims decided. The run header's concurrency chip, read from the manifest, says so in its visible text ("parallelism: 4 (manifest)"), not only in its hover. Step counts read "1 step" (one stepCount helper). An unpriced-runs line over a paged list adds "· the loaded rows". "(content not captured)" (contentOff) is gated on isLive and on a gap / not_recorded hole. Beside a cancelled status word the stop chip reads the cause alone. A tool failure of context.DeadlineExceeded reads "deadline". The kind chip reads "[retry budget spent]". The unused statsStrip helper is removed. The panel's subagent row and the playground's compare table take their status words through statusOf. Panel +176 B (82,549 B).
  • Studio, panel: the pre-tag re-review (D7). Beside a cancelled status word, a stop chip with no recorded cause reads the step it fired at ("· step N"), so the run header keeps its link to that step. "(content not captured)" no longer shows for a list row whose events were lost (event_count below events_emitted). A session row's content_off is gated like the client's — not while the first turn is running or settling — on both backends' session list and document and in a panel token's scoped document, through one rule, obsdb.SessionContentOff (new; its Status and Settling derived by DeriveStatus / DeriveSettling); conformance case in SessionTitles. The approvals block uses the shared AWAITING words. A test holds studio/README.md's panel figures to the ledger's last row. Panel +59 B (82,608 B).

Breaking

  • Breaking (record): an execute_tool span's error.type for a contained panic is panic (with weft.panic.layer), and for weft/mcp failures mcp_transport / mcp_tool_error — all three were tool_error. A query or alert keyed on tool_error misses them (U4).
  • Breaking (reader): a tracer-only app's runs, and runs whose record tail a destination dropped, read succeeded / failed from their stored invoke_agent span, no longer interrupted; ?status=interrupted / ?status=succeeded / ?status=failed results change accordingly (UX-E-01).
  • Breaking (API): a weft run with spans and no stored record badges not_recorded / cause no_logger (or gap / events_lost when its root counted records), no longer derived — derived is a stock-span run's badge alone (UX-E-03).
  • Breaking (Go): the obsdb.DB interface gains CountLogs (U4): an implementation outside this module must add it.
  • Breaking (Go): obsdb.PromptRecord carries Segments (a slice) and is no longer comparable: == on it, or its use as a map key, no longer compiles (U32.1).
  • Breaking (API): the run row's meta no longer carries weft.run.stopped_by or weft.cancel.cause — they are contract keys, served as stopped_by / cancel_cause (U3.1 fix).
  • Breaking (API values): cost_badge gains the value scripted (an exact $0 by rule for wefttest/* and the weft/runtime scripted engine); cost_reason texts changed (one reason per page, "openai/x has no price"); weftarena/* gets no $0 rule and stays unpriced without a row of its own (U7).
  • Breaking (Go): an INVALID_INPUT tool error's ToolError.Err is now an unexported *inputError wrapping ErrInvalidToolInput, no longer the sentinel itself: errors.Is(err, ErrInvalidToolInput) and the model-visible text are unchanged, but te.Err == ErrInvalidToolInput is false (U4).
  • Breaking (CLI): the weft runs table gains the PROMPT and STOPPED BY columns after STEPS: a script that splits rows by position must move to weft runs --json (U3.2, U6).
  • Changed: the ErrModelRetriesExceeded run error's text adds the budget — tool "x" retried 3 times, past MaxModelRetries(2) (was tool "x" retried 3 times); errors.Is on the sentinel is unchanged (U32.1).