Skip to content

v0.18.0

Choose a tag to compare

@wajihtba wajihtba released this 20 Oct 09:20
· 183 commits to main since this release

Added

  • core, runtime, thread: core.ApproveBy(callID, by, grantID) — Approve naming its provenance, reporting only: the call's execute_tool span carries weft.tool.approved_by and weft.grant.id (span attributes, never meta). weft/thread passes it on every resume (user, grant with the grant entry's id, approver, quorum, policy), and a playground approval of a runtime-started run passes runtime. Agent.TracerProvider() (resolved at New) and Agent.ContentCapture() (whether Content was given, and its value): what a satellite reporting beside the runs reads; weft/thread uses both (UX-B-14, UX-S3-11).
  • thread, otel, obsdb: the session's state log as OTel records (U12.1; O14; ADR 0021 amended 2026-10-19: an index, not evidence). weft/thread reports every state fact it writes as a log record of kind thread (event weft.thread.<kind>, scope github.com/weftgo/weft/thread) through the agent's LoggerProvider: approval.request, approval.decision (outcome, via — user, signed, grant, approver, expiry, interrupt, child, parent, quorum — key_id, request_entry, always, grant_id), approval.refused (bad_signature, replayed, expired, args_changed, not_pending), grant.mint / use / revoke / expire, receipt (queued with the turn number it took, accepted, delivered, deferred, dropped), interrupt, rewind, branch, uncompact, label, pin, fork and thread/pool's pool.delegate / pool.retire. Each is filed under the run it concerns (the entry's run, else the turn in flight, else the last run on the leaf's line), held until that run reports, and what is still held at Close goes out held: true under no run, keyed by weft.session.id. Decided under the session lock, emitted by one goroutine per Session (never under it; Close waits). Ids, enums and counts always ride; who, reasons, resolved content and texts only under the body's content object — stripped content-off, redacted content-on under the new otel.ContentThread kind and capped by MaxBytes; a label's name is metadata. obsdb stores one as a plain record under its run (obsdb.RecordThread), never as an app log line. A panicking or refusing logger changes nothing the session does.
  • thread, obsdb, Studio: the summarizer's cost (O15; UX-B-13). Every summary model call of a compaction or a SummarizeLeft branch is a chat span with weft.chat.role summarizer, its model and usage, under the turn in flight or the last run — never in that run's usage, provider, model, meta, started or last_seen: the span is stored and summed apart, but feeds no column of the run row, so a summary made between turns never restarts a finished run's Retain clock, never reads an interrupted run running again, and never recreates a run DeleteRun removed (obsdb.AttrChatRole, obsdb.ChatRoleSummarizer; SQLite skips the row at ingest, ClickHouse 0024 restates the traces run view without the span; obsdbtest.SummarizerSpanIsNotRunFact holds a backend to it on one database, as its upgrade test runs it over an older schema); one landing after DeleteRun stays stored under the gone id until SQLite's Sweep takes the spans older than its cutoff that no run row names, in batches of obsdb.SweepBatch as its runless session events (ClickHouse: the spans' TTL); a failed call's span carries error.type summarizer, and one whose run never reported is dropped with a Debug line. The compaction marker's body gains summarizer_model / summarizer_usage. obsdb.SessionRow.SummarizerUsage sums a session's summarizer spans apart from Usage, SummarizerModels splits it by model (obsdb.ModelUsage, obsdb.SumModelUsage); Studio serves summarizer_usage on session rows, prices it per model on the timeline and each turn's run document (summarizer; a call with no recorded model an estimate at its turn's model, cost_badge estimate, drawn "≈"), drawn "+ summarizer $x" beside the turn's, the session's and the panel's open turn's cost.
  • thread: thread.WithRunMetadata(kv) — a session's run metadata on every turn's run, written into the header at Create / Fork (Header.RunMeta; a fork takes its origin's), held over it at Open, read back with Session.RunMeta(); identity keys win, a Send's own core.Metadata wins per key (UX-B-26). A turn names its branch: weft.turn.parent (the turn whose line it continues, on the first turn after a Branch, Uncompact or rollback) and weft.session.branch (the label on the entry branched to, else its id); a failed send's leaf move back names nothing (thread.LeafEntry.Abandoned) (UX-E-20).
  • obsdb, Studio: the session timeline index (U12.2; D6). Every thread record and compaction marker carrying a weft.session.id is indexed by session at ingest (SQLite session_events, migration 0022; ClickHouse weft_session_events, 0025, a materialized view under the content TTL; both backfilled), runless ones included. New optional obsdb.SessionEventReader — SessionEvents(ctx, obsdb.SessionEventQuery{SessionID, PublicID, RunIDs, Kinds, Offset, Limit}) → obsdb.SessionEventPage of obsdb.SessionEvent, ordered by time, run, kind, then body (obsdb.MaxSessionEventLimit, obsdb.IsSessionEventKind, obsdb.SessionEventName, obsdb.SessionEventKind) — implemented by both backends ((*sqlite.DB).SessionEvents, (*clickhouse.DB).SessionEvents); DeleteRun, DeleteSession and Sweep remove the events with their runs. Studio: GET /api/sessions/{id}/timeline (turns in order, marks in record-time order, at most 2,000, each at / kind / event / lane / run_id / turn / entry / fields / content, a decision's actor — the weft.approval.actor of the turn that resumed the run it is filed under (never that run's own, which names who decided the run before it; absent while no stored turn resumed it, or when the turn that did carries none, the timeline's drawer and approvals view then saying the decider is not recorded yet, or not recorded by that resuming turn (named, with the fix: decide with a who, or resume with weft.approval.actor set) — or hidden, to a read token), the call's own decider when the resume names several and the record says which (key_id, else to a content reader its who; else the list, actor_of: "resume"), metadata every token reads, as the run page's decided block does; lanes approvals, grants, compaction, steer_queue, branch_fork, pool, other; forks; holes not_recorded when a turn parked or resumed and no mark is stored, stripped, hidden, truncated with cause: "response_cap") and GET /api/sessions/{id}/entries?offset=&limit=&run= (≤ 1,000, next_offset; run one run's marks alone, its holes and hidden count that run's too, obsdb.SessionEventQuery.RunIDs), capability timeline; GET /api/sessions?tag.<k>=<v> (obsdb.SessionQuery.Meta; ClickHouse narrows it, and RunQuery.Meta, on migration 0026's idx_meta ngram index over Meta).
  • Studio: the session page (U15.1, U15.2; UX-B-01, UX-S3-01, UX-B-07, UX-B-08, UX-B-12). /sessions/$id opens on the timeline: one row group per exchange (a turn and the resumes that decided its parked calls) on a shared time axis, each turn's marks under it in the state log's words, a skipped turn number said with the receipt that took it ("t2 · consumed by a steer · delivered into t1"), unexplained gaps one not_recorded hole, a drawer per mark (?mark=) linking the parking step and the resuming turn, the lane filter (?lanes=, dims, never hides), live on /api/live?session= ("● live · a turn is running"). Views: ?view=tree — the lines the turns continue (weft.turn.parent, weft.session.branch), abandoned turns "✗ abandoned · off path"; ?view=approvals — every decision (call, tool, outcome, who / via / key, grant, parked → resumed turn, each a link to the call's row, refused signed decisions, calls awaiting one) and every grant (rule, args globs, scope, uses n of max with their calls, revoked / expired); past the 2,000-mark cap every view draws the truncated hole and the header's counts read as floors ("≥ 12 approvals"), and a parked call's request expiry is read from its own run's marks; ?view=table — the turns, newest first (?order=oldest), each with its start to the millisecond, the wait before it ("+0.5s waited for a decision"), duration and own cost. Forks are named on the origin (each fork's stored turns and status, "no turn recorded yet" for one that ran none; obsdb.SessionQuery.IDs, obsdb.SessionForkedFromKey) and on the fork ("⑃ fork of … at entry e9 (“…”)"). Compaction: a context lane per turn (input tokens, reason, tokens before → after), "started on a compacted context" on the turn that did, and on the run header a chip per compaction ("⌗ compacted at step 3 (4 → 1)", "⌗ session compacted after this run"). A busy policy's end reads neutral everywhere — "✗ interrupted by t7", "↶ rolled back by t9 · off path" (weft.cancel.cause thread.interrupted, obsdb.BusyCancelCause). A turn's run page names its session (breadcrumb "sessions › s_… › t4", a stepper "‹ t3 · turn 4 of 9 · t5 ›" with { / }, "t6 · 4 of 7 recorded turns" where the turn numbers skip), read in O(1) rows: GET /api/runs gains turn_below / turn_above and oldest (obsdb.RunQuery / SearchQuery TurnBelow, TurnAbove, Oldest, both backends).
  • Studio: session actions (U31.2; UX-B-18, UX-B-19, UX-B-20, UX-B-29, UX-B-40, UX-B-41, UX-S3-16, UX-S3-21). GET /api/sessions/{id}/export?format=jsonl downloads a conversation (<session>.session.jsonl, weft.session.export/1, streamed one turn at a time: a session line, every turn's run export in turn order — at most 200 —, session_mark lines — at most 2,000 —, session_hole lines, and a closing session_end line {turns_exported, marks_exported, bytes}; past 64 MiB of lines before the holes — the session line, the turns', the marks' — a truncated hole, cause response_cap, and the timeline's holes when the turns ran to the end — when they stopped, its hidden (a panel token's marks) and not_recorded alone, the scope read's; studio/README.md has the shape). The session header gains copy public id, export jsonl, open in app (the newest turn's weft.page_url), delete session… (the app's thread store untouched) and a storage line (the app's storage turn metadata, else not_recorded). A parked call shows its request expiry from the approval.request record — "expires 09:57:56 (in 40.0s)", then "⏱ expired … — denied at the next touch (lazy expiry)"; the panel's approvals block shows the same expiry (read with the turn from /entries?run= — an answer missing an awaiting call's approval.request record (one with no expiry is final), or a failed one, read again with the next reload and once 2 s on, three reads at most, a failed re-read keeping the expiries read before; the clock redrawing when the words next change and stopping at the lapse, which both surfaces put past the stamp, as thread does; an unparsable stamp dropped; a truncated badge past that read's cap and a not_recorded one — on the run page too — when it fails with nothing read before).
  • obsdb, Studio, panel: sessions list states and the conversation page (U16; UX-B-15, UX-B-16, UX-B-28, UX-B-30, UX-F-02). A session row carries its turn facts, counted in SQL on both backends (obsdb.SessionTurnFacts; obsdb.SessionRow Pending, FailedTurns, EndedTurns, CancelledTurns, Resumes, Outcomes — obsdb.TurnOutcome, obsdb.OutcomeOf, OutcomeSucceeded, OutcomeFailed, OutcomeRunning, OutcomeParked, OutcomeResumed, OutcomeInterrupted, OutcomeEnded, OutcomeCancelled, obsdb.MaxOutcomes — and ForkedFrom; the API's pending, failed_turns, ended_turns, cancelled_turns, resumes, outcomes, forked_from_session): the list says "⏸ awaiting a decision · 1 call", draws the newest 40 turns' outcome strip, counts failed ("1✗ failed", red), busy-ended ("2✗ ended by a newer message") and cancelled ("1⊘ cancelled") turns apart, says "3 exchanges · 5 turns", and filters by chips — GET /api/sessions?awaiting=1&failed=1&compacted=1&lineage=child|fork&status=&since= (obsdb.SessionQuery Awaiting, Failed, Compacted, Lineage — obsdb.LineageChild / LineageFork —, Status — the newest turn's outcome as the session page words it: a cancelled or busy-ended turn is not failed, a parked one not succeeded —, Since; a malformed value a 400; the list's state word is that outcome; obsdbtest.CheckTurnFactFilters holds both backends to Go's rule). The session header offers "decide…" on the parked turn and links its public id to /conversations/$id: every run of a public id with experiments nested under their source turn, its thread sessions, own and delegated cost apart, newest first (?order=oldest), each gap measured within its own session (a run of no session, within the public id's other sessionless runs), live on /api/live?public_id=. Panel: GET /api/runs narrowed by public_id or session carries each turn's session_compactions, delegated_sessions and ended_by / ended_by_turn / ended_kind (read with obsdb.SessionEventQuery.Kinds, past 50 sessions a page the turn_marks_capped hole), drawn as turn chips "⌗", "↳ 1", "✗ interrupted by t8" / "↶ rolled back by t9 · off path"; a header hand-off "timeline ⤢" or "conversation ⤢"; a scope naming a session lists its own turns with the public id's experiments nested.
  • obsdb, Studio, panel: delegated sessions and caller-linked runs (U14; D3; UX-B-05, UX-B-06, UX-B-35). A run's caller link is a row column (caller_run_id / caller_call_id, SQLite 0021, ClickHouse 0023; obsdb.RunRow / RunDetail / Weft CallerRunID, CallerCallID; RunQuery.CallerRunID, SearchQuery.CallerRunID). A session row carries its lineage (parent_session, parent_turn, parent_call, parent_run; obsdb.SessionRow.ParentSession / ParentCall / ParentRun) and obsdb.SessionQuery.Parents reads one level of a delegation tree. GET /api/sessions/{id} adds delegated (each delegated session with its cost), delegated_more / _unknown, and the whole tree's delegated_usage, delegated_sessions_cost_usd / _known / _reason and delegated_sessions_cache, beside — never folded into — its own; a panel token not shown some of them, at any depth, reads one delegated_holes hidden hole with their count (a hidden session's own delegated sessions, at every depth, counted too — their ids read, never shown; past 500 hidden sessions, past two pages of 500 read for one level's hidden ids, or below a walk stopped early, count_floor: true and "at least N"; a session counted once, however forged its lineage) and the delegated cost unknown, never $0 (the runs read per level in batches of 50 sessions, obsdb.RunQuery.SessionIDs / SearchQuery.SessionIDs — a batch past 10,000 runs, by its first page's count, halved, so each session prices up to 10,000 runs of its own, exactly 10,000 included, a session past that unknown; a level reads at most 20 pages per 50 sessions and 20 more, the sessions it leaves unread unknown with the reason; past 500 sessions unknown). GET /api/runs/{id} adds caller_linked (the first 100 in start order; caller_linked_more, _hidden for a panel token, _unknown, drawn as truncated / hidden / not_recorded). Both surfaces word a pool child "delegated session", never "subagent", with its lineage as links ("↳ delegated by s_… · turn 1 · call d1"); the panel's call badge opens the delegated session.
  • Studio, panel: approval decisions visible (U13.2; UX-B-03, UX-H-17, UX-S3-04, UX-H-20). GET /api/runs/{id} serves decided on a parked run its resume names and resumes on that resume: each call's outcome (approved, denied, resolved, expired, no_decision), via / key_id / grant_id / at, who, a denial's reason, a resolve's result, the resume's actor (weft.approval.actor, metadata on every surface) — from weft.resume.calls, else the pre-step results, else the step-0 execute_tool spans (derived), else a not_recorded hole; thread's approval.decision records read by obsdb.SessionEventQuery.RunIDs, a met quorum naming every approver, a split verdict its denier apart ("✋ denied by bob · approved by alice"); read-scoped tokens get who_hidden / reason_badge / result_badge; notes name what could not be read. The parked card reads its outcome per call on the run page and in the panel ("✋ denied · agent-7 · 'need a photo of the damage' → t2"), the resume opens with "resumed from t1 · 1 decided call" (with what each call left: "→ refund of 249.90 issued"), step 0 no longer draws the decided calls as its own, the story's tools line counts "1 decided" among the parked calls alone (never a negative "awaiting"), and a run page's approved call says "✓ approved · by grant g_…". obsdb.AttrTrue reads weft.tool.denied / weft.tool.pending as a bool or "true".
  • Studio, panel: the loop graph (U34; WEFT-LOOP-GRAPH.md; UX-S3-14). The run tree's graph scale opens on the selected run's loop — prompt → model ⇄ one node per tool (calls in call order, failures, RETRYs as ↻, denials, parks with a dashed edge; MCP tools framed by server), the end in the stopped-by words, offered tools never called drawn faint — from the ?tree=1 spans or, without spans, the run's fold; an insight strip (a budget end, the output limit, a repeated identical call, a tool that always fails, never called), subagents expanded in place with a breadcrumb and their own cost, a step scrubber (?step=, J / K moving the selection with it, ▶ play; Esc clears the selection until the next pick), the shared ?sel= and drawer with ‹ call i of n ›; ?level=agents is the agents' call graph (a run that recorded no spans but has subagent rows draws it from the run rows, a not recorded badge naming what that leaves out: MCP servers, tokens); both graphs fit their pane down to 0.9 of their size (their text never under 9 px), then scroll sideways in their own focusable region, never the page; read failures said in words. The panel's Story tab draws the loop in one line, each tool chip selecting its first call, graph ⤢ handing off.
  • obsdb: obsdb.TreeCutParent, the parent a backend maps a run to in the parents it hands TreeMembers (and TreeReadIDs, CutRunTree, CapTree) when its read found the run but may not show it — a run past ClickHouse's retention cut. No run has it, so the run is no member, nor is a run whose stored parent it is, and core's id rule stops at it: an id-shaped descendant of a cut run stays out with it instead of joining the nearest member above. A backend that cuts nothing (SQLite) never uses it (U31.1).
  • Record contract §2 (weft.caller.*, WithRunMetadata, weft.chat.role), §3 (the summarizer's span), §4 (kind thread and every kind's fields; the tool-event order rule), §6 (sessions over own turns, the timeline, decisions); conformance cases pool_child, thread_approvals, thread_grants, thread_busy, thread_compaction, thread_summarizer, thread_held, thread_branches, thread_pool, thread_content_off, read back through obsdb.SessionEventReader on both backends; docs/thread-operations.md §13.

Changed

  • Studio, panel: one decision vocabulary (lib/decisions.ts): the verbs read approve · deny · resolve… on the run page, the playground's result cards, the panel's approvals, result pane and parked toast (they read continue · skip · resolve); the state is "awaiting a decision" (was "pending approval" / "awaiting approval"), a finished decision its outcome, not "decided" (U13.2).
  • Studio, panel: a busy-policy end (thread.interrupted) reads neutral — the run header's "ended · interrupted by t7", the flow strip, the run tree, the story's "■ interrupted by t7 at step 0", the drawer — and a cancel with any other cause keeps the failure's red (U15.2).
  • Studio: contrast. --faint (eyebrows and every text-faint label) is text, ≥ 4.5:1 on every surface in both themes (#716a5c light, #948f82 dark), decoration on its own --faint-deco; JSON literals and error results in --code-lit, every code-* token ≥ 4.5:1 on --code-bg; --status-int, --ev-error and the panel's --weft-warn are #875c04 (were #9a6a06, 4.38:1 on a subagent block's wash); the sidebar is a navigation landmark; axe zero on the run page, both themes, with the target-size rule on too (24 × 24: the timeline's marks, the panel's filter checkbox, call links, editable args and results (each result, message and insert on its own line), hand-off links, the export's "with child runs" checkbox, the session tree's mark links, a delegated session's lineage links, the run tree's expand / collapse, the drawer's verbs and tab links, the loop graph's steps, play and insights, a step's call stepper and the subagent block's rows, a call row's toggle, the story's step scrubber, a step's request / wire tabs and its catalog and system-prompt toggles, the raw view's tabs, filter chips and event rows, the JSON tree's expand / collapse take a minimum hit area; a JSON tree on its code surface wraps long strings and scrolls inside it, so a deep request message never widens the page at 360 px); the loop and call graphs' nodes carry their tooltip as their description (a <title> on a wrapper and a line under the graph while one holds focus), so a node's accessible name is its drawn words (axe label-content-name-mismatch); palette.test.ts checks text on each wash (U34, U31.2).
  • devtools panel: 90,778 B gzipped (90,091 B at 0.17.0; cap 96 KiB) — the decisions, the loop line, the session chips and the delegated turns, paid for by property mangling over the internal names scripts/panel-mangle.ts proves (a build fails on a value widened to an opaque type — a standard container's opaque type argument included — or read by a quoted name) (the host API, wire, DOM and storage names stay verbatim) and folded duplicates; studio/web/panel-budget.json has the ledger.

Breaking

  • Breaking (record): only a core.Subagent child carries weft.parent.run.id / weft.parent.call.id; any other run started on a tool call's context — a thread/pool child session's turn, another agent's Generate in a handler, a run under a Subagent child's own tool call — carries weft.caller.run.id / weft.caller.call.id on its invoke_agent span and run_start record (a link, not parenthood), and a run started inside a run's machinery (its model chain, PrepareStep, a tap, OnRunEnd: a judge) carries neither — under a Subagent child it claimed the child's weft.parent.* (U14; O11; record contract §2). A reader that keyed pool children or nested Generates on weft.parent.* reads weft.caller.* instead; Studio reads both — records written before 0.18.0 keep their weft.parent.* link.
  • Breaking (API): a caller-linked run has no parent_run_id: it is no longer in a run's children, children_count, ?tree=1 spans or tree=1 export — read it from the row's caller_run_id / caller_call_id and the run document's caller_linked instead. GET /api/sessions groups a session over its own turns (<session>-t<turn>, or a run linked to no run), so a pool child session is listed and counted in total, and GET /api/sessions/{id} answers it instead of 404; a client after top-level sessions only skips rows with a parent_session (U14; D3). A session row's and document's status is its newest turn's outcome (obsdb.OutcomeOf): for the four run words — succeeded, failed, running, interrupted — it agrees with ?status=; the other four — cancelled, ended, parked, resumed — are not filterable (?status= with one is a 400; awaiting=1 is the open park). Before 0.18.0 it read failed for a cancelled or busy-ended turn and succeeded for a parked one: read the outcome word instead, or the newest run's own status from /api/runs?session= (U16).
  • Breaking (Go API): obsdb.SessionQuery (Parents, IDs, Meta) and obsdb.SessionRow (SummarizerModels, Outcomes) are no longer comparable: == on them, or their use as a map key, no longer compiles — compare the fields you need instead, or key the map on ID (U14, U15.1, U16).
  • Breaking (record): a trace holds chat spans for thread's summarizer calls; a reader that sums gen_ai.usage.* over a run's chat spans as its model use skips those with weft.chat.role summarizer instead of counting every one, as obsdb and Studio do (O15).
  • Breaking (record): weft.approval.actor on a Quorum(n > 1) resume names every key (or Who) that counted toward the quorum — the first n distinct approvers —, sorted, deduped and comma-joined — it was the last signer alone; split it on , instead of reading one key (U31.1).
  • Breaking (CLI): weft mcp's run summary and explain_failure say "N calls awaiting a decision" (were "pending approval" / "awaiting approval"): a client matching the text matches the new words instead, or reads pending (U13.2).
  • Breaking (API): GET /api/diff's side keeps its hidden hole's count (child runs a panel token's scope left out) beside a withheld prompt — at v0.17.0 a read token's hidden hole carried no count. A client that took a count-less hidden hole for "the system prompt is hidden" reads the side's system_hidden instead, not a count-less hidden hole (U31.1).
  • Breaking (UI links): /sessions/$id opens on the timeline; a link meant for the turns table adds ?view=table instead, whose rows are newest first — &order=oldest for the old order (U15.1, U31.2).
  • Breaking (UI links): ?scale=graph on /runs/$id opens the loop graph; a link meant for the agents' call graph adds &level=agents instead (U34).

Upgrade notes

  • Migrations run at Open: SQLite 20 → 22 (0021 the caller columns and the session-lineage indexes, 0022 session_events, backfilled from records), ClickHouse 22 → 27 (0023 the caller and SessionParent columns, backfilled by a background mutation; 0024 the run views; 0025 weft_session_events, backfilled from otel_logs; 0026 the idx_meta index, materialized by a background mutation — until it finishes the older parts are read in full, as before; 0027 0024's traces view restated, for a development database that applied 0024's first text — a summarizer span then still fed the run row — and the same view again on any other). Timed on v0.17.0 databases through 0025: SQLite 1.7 ms, a 25,011-run 358 MB SQLite file 151 ms, ClickHouse 246 ms, every count unchanged; ClickHouse 22 → 27 378 ms (900 runs), development databases at 0025 and 0026 holding 0024's first view 158 ms and 81 ms, every count unchanged, no mutation left running. ClickHouse 25.6 or later (obsdb/clickhouse/README.md).
  • Sessions recorded before 0.18.0 show only compaction markers on their timeline: the thread records start with 0.18.0 (a parked or resumed turn there reads the not_recorded hole).
  • obsdb/clickhouse: every read that lists or opens runs cuts at weft_runs' TTL — Runs (every RunQuery filter, the turn neighbours included) and Search, Sessions (its rows, Total, the turn facts, the tag and Failed filters, the summarizer usage), Session (its row and its runs), Run and its children, ChildRuns, RunTreeSpans (its rows and the spans of the runs it keeps; a run under a cut parent is out with it, by its stored parent or by core's id rule alike — obsdb.TreeCutParent marks a run a backend found but cut —, and a cut run, a derived one included, is ErrNotFound as in Run) and ResolvePublicID — so a run past its window is no longer listed, counted or opened, and no read keeps a turn or a child another cut, whether or not a merge has dropped its rows yet: Studio's children[], children_count, ?tree=1 and the cost's subtraction drop a cut child together. The boundary is one instant per call on the server's clock (the host's now plus the server's offset, read with the window, minus the window), so the statements of one read never disagree across it and the cut follows the TTL merges' clock. A failed read of the window keeps the last one known for a minute, logged at Debug — a read the caller cancelled holds nothing —; with none known yet, or a window past about 100 years, nothing is cut. A cut resume is no resumer, on the same instant as the rest of the call: its parked run reads parked again — no resumed_by (so Studio's run document carries no decided block), in RunQuery.Status / ?status=parked (Runs and Search) and SessionQuery.Awaiting / ?awaiting=1, the session's pending, resumes and outcomes (Sessions, Session) and its ?status= agreeing; SQLite, which cuts nothing, reads a parked run whose resume DeleteRun removed the same way. A run's own records and spans read by id until the merge drops them (U31.1).
  • WEFT_DISCOVERY=off now also stops weft studio / weft dev writing the discovery file; the start says so in one line (U31.1).

Fixed

  • core: a TracerProvider or LoggerProvider that panics no longer crashes the run (P9). Every call core makes into its providers — Tracer / Logger at New, Start and every span call of the invoke_agent, chat, execute_tool and attempt spans, the records' Enabled and Emit — is recovered: the report is dropped, counted in Agent.TapPanics and weft.tap.panics, and named in one Warn line per run per provider kind; a span whose attribute calls panic is still ended; the run's result and events equal the no-provider run's. User code keeps its own rules. Studio's problem strip reads "tap panics: N (observers or providers, contained)".
  • core: a tool error whose own methods panic (Error, Is, As, ErrorType) no longer crashes the process — before, a panicking ErrorType did under a recording tracer (P9; AGENTS rule 1). The loop reads a failed call's facts once, inside the tool chain's panic containment, with or without a tracer: a panic there is the call's tool "X" panicked: <value> result (error.type panic, weft.panic.layer handler or middleware), siblings keep running. Under an expired Timeout a deadline error is the timed out result first. A model or run error's methods keep their behaviour.
  • core: the tool-event order rule is stated (AGENTS rule 3, record contract §4) and pinned by TestToolEventOrderRule — ToolStarts in call order, each ToolFinish after its own ToolStart, finishes in completion order, a batch not fenced; nothing changed in the loop, and two tests that assumed more no longer flake.
  • thread: a compaction marker still waiting for a run at Close goes out held: true under no run, keyed by the session's metadata, instead of being dropped with a Debug line; both backends index it by session (U31.1).
  • obsdb/clickhouse, Studio: the READMEs named ClickHouse 23.3 as enough; the schema needs 25.6 or later — migration 0001's TTLs are on DateTime64(9) columns, which 25.5 and older refuse ("TTL expression result column should have DateTime or Date type") — now stated in obsdb/clickhouse/README.md and named by TestServerAtTheFloor (23.3 is lightweight DELETE's own floor) (P9).
  • obsdb/sqlite: a run tree's row cap counts members, not every id under the run — app-shaped ids (<run>/job/N) no longer push a real descendant past the cap silently (U31.1).
  • Studio: the diff's step-cap truncated hole carries cause: response_cap, as documented; a diff side says system_hidden: true when its prompt is withheld from the token (U31.1).
  • Studio: the session page no longer says "the session document is capped": the document carries every turn (U31.2; UX-B-23).
  • Studio: a tool result's tone applies — an error result in --code-lit, a non-JSON result in --code-str — where the code window's default ink used to win (P9).
  • Studio: a page whose read answered 403 offers no retry — the same token reads the same 403 — and says so instead (P9).
  • Studio: under a panel token an unknown session id answers 403, as another public id's session does, on sessions/{id} and its timeline, entries and export (was 404: the difference named which ids exist); a session whose stored marks are all session-level (no turn stored) answers sessions/{id} as its timeline does, 200, where its own token read 403 (P9).
  • Studio: the armed g chord disarms on a key typed into a field or held with ctrl / meta / alt, which keeps its key (U31.2).
  • devtools panel: two panels on one page say the same "(chained)" whichever installed its fetch wrapper first (U31.2).