Compare commits

...
Author SHA1 Message Date
yhh 2fadb2df7e docs(studio): fold the symlink-skip fix into the 2.9.0 notes 2026-08-26 21:30:23 -07:00
yhh 72dfeaadf8 fix(installsource): a symlink case skips on what the filesystem answers, not on the OS name
TestApplyLocalSkillLinkMode asked runtime.GOOS, and asked it after asserting
resp.OK — so a machine that cannot create a symlink failed on the refusal the
tool had correctly reported, rather than skipping. Windows allows symlinks under
Developer Mode or elevation, which the platform name cannot tell apart from a
plain account, so the check is now the read three cases in this file already
make: try it, and skip on the error the filesystem gives back. A Windows host
that can make one now runs the case instead of skipping it.

Also ignore *.exe~ — what the linker leaves when it cannot replace a binary that
is still running, which on Windows is every rebuild while Studio is open.
2026-08-26 21:27:38 -07:00
yhh 384e0030cb docs(studio): release notes for 2.9.0 2026-08-26 20:58:58 -07:00
yhh fb0e70a798 fix(studio): a control on the composer shelf never breaks its own label
The shelf's pills were ordinary flex children, so a row one pixel short of
fitting squeezed them instead of wrapping — and Chinese breaks between any two
characters, so 计划 came apart into two lines and took the row's baseline with
it. A pill is now its label's width or nothing: the shelf wraps, which .row
already allowed, and the send button's auto margin keeps it at the right end of
whichever line it lands on. Verified from 980px down to 320px: one line, two,
then three, with nothing broken at any of them.

The model pill is the one control with no width of its own — a gateway can
publish an id longer than the shelf — so it gives up characters before the
shelf gives up a line, and the ref it was shortened from stays in the tooltip.

Closes #9455
2026-08-26 20:11:07 -07:00
yhh 5654e6aa6b feat(remote): a machine holds every project on it, not one fixed workspace
The attach pool has always keyed spaces by workspace and named a forward per
one; the far kernel remembers every folder a pane opens and answers /tree with
all of them. Only the book could name a single directory, so nothing could say
the second one — and cold, before a pane exists to ask through, the sidebar had
just that one row.

RemoteHostEntry gains workspaces beside workspace, folded by WorkspaceList into
one default-first list so a row that only ever set the old field is a list of
one rather than a case every reader repeats, and an upsert makes the head of
the list the default the CLI still connects to. The sidebar shows the book and
the far tree together: a folder added here and never opened over there stays,
and one opened over there appears without being written down here.

The [remote] renderer moves to render_remote.go — the section is one subject,
and the function it came from is already the longest in the package.

Closes #9459
2026-08-26 19:58:48 -07:00
yhh 5e036629c7 fix(studio): a question the run is blocked on survives the transcript rebuild
The kernel replays a pending ask/approval to every client that attaches, and
the window then overwrote its items with what /history rebuilds — where the ask
tool call is deliberately drawn as nothing, because ask_request carries the id.
The pane sat at 等你决定 with nothing on screen to decide and the queued turn
behind it never dispatched. A prompt is not part of the record; it is the run
stopped waiting on an answer, so it rides across the rebuild. prompts.ts also
gives one its kernel id, so a replayed frame is the same card rather than a
second answerable copy, and an answered one is not reopened by the frame log.
2026-08-26 19:58:25 -07:00
yhh 82d335f089 fix(studio): the sandbox pane reports the jail that runs, not the word in the file
SandboxSettings carries effectiveBash beside effectiveWriteRoots, so the pane
draws what the confiner will do rather than what was configured: an unset mode
enforces everywhere but Windows, which runs unconfined however the file reads.
The segment for a mode this host cannot run is visibly dead and carries its own
reason — `disabled` alone kept the hover of a live control and then swallowed
the click, which is what was reported as broken. Enforce-without-a-backend
refuses by code rather than by a hand-written sentence, and the SandboxSettings
mirror is declared to wire-parity so the next field cannot drift.

Closes #9454
2026-08-26 19:58:13 -07:00
yhh f362569953 fix(control): a refusal says what it was, and the resolver and message tables follow
errmsg and the refusal-hint test, instruction's resolver, and the i18n message
tables the two of them read from.

Accumulated working-tree work, committed as found; the tree builds and passes at
the tip.
2026-08-26 18:21:38 -07:00
yhh 46dd117e6e feat(remote): bootstrapping a host is one path with its own tests
install, launch, platform and the Windows shell case behind one bootstrap, with
the hardening and pure-function tests that let it be exercised without a machine
on the other end, and the hub and CLI entry points that reach it.

Accumulated working-tree work, committed as found; the tree builds and passes at
the tip.
2026-08-26 18:21:38 -07:00
yhh e1752b292e fix(provider): a reasoning stream that breaks is replayed rather than lost
reasoning_replay for OpenAI and Anthropic, the h2 reset case, stream_failure and
the retry path around them, with doctor reporting what the provider actually did
so a broken stream is diagnosable instead of just slow.

Accumulated working-tree work, committed as found; the tree builds and passes at
the tip.
2026-08-26 18:21:37 -07:00
yhh b72fe285ae feat(theme): cloudsea-gilt ships as a built-in pack
The palette, the picture-less sky and the ray/cloud tints the Studio design was
drawn against, as a pack the kernel serves rather than something a user has to
install to see the app as designed.

Accumulated working-tree work, committed as found; the tree builds and passes at
the tip.
2026-08-26 18:21:37 -07:00
yhh 5836680d65 feat(studio): the rail becomes panels, and the window gets a sky behind it
RunStrip was one strip carrying every reading. It splits into panels — Overview,
Runtime, Cache, Cost, Context, Agents, Files, Jobs — with derive.ts doing the
walk once in the pane and kit.tsx holding the shapes they share, so a reading
that grows does not widen a row every other reading has to live in.

Alongside: Sky draws the backdrop a theme pack asks for rather than placing a
picture, Nav takes the icon rail, Timeline and Task give the trajectory and a
subagent their own views, AccountRow the account, and prefs/keys/trailing carry
the state, the bindings and the tail the panes were each keeping privately.

perf grows three scripts: selfcheck, which pushes a false assertion into every
guard to prove the guard can still go red; sourcemap, which turns a minified
frame back into a source position; and switchprof, which samples the cost of
opening a long session. look and verify grow the assertions they were missing.

This is accumulated working-tree work, not written in the session that committed
it; the message describes what is here rather than the reasoning behind each
piece. The whole tree builds, vets, typechecks, and passes its tests and
repolint at the tip.
2026-08-26 18:21:10 -07:00
yhh 6811f1224c feat(perf): two guards for what a stylesheet promises and what a build ships
Both stand for a failure that stayed silent while every other gate was green.

tokens.mjs: a var(--x) with no fallback has to name something someone assigns.
CSS drops the whole declaration when it does not, and the property falls back to
whatever was inherited, which reads as the design. Two of those were live in the
tree — the connection prompt's scrim named a --bg that has never existed, so it
had blur and no dimming at all, and the usage panel's --fg meant its text colour
never landed. Both lists are read from source, the stylesheets' own `--x:` and
the per-element ones JSX writes into style, because a hand-copied list rots.

shipped.mjs: dist must be newer than everything under src. A build can succeed,
print its tick, and compile something else — this tree has more than one process
on it, and a stash that put it back to HEAD for forty-eight seconds was enough
for one build to fall inside the window and produce the previous release. The
window then loaded it and the whole interface reverted, while the tests stayed
green: they read the source. Timestamps rather than marker strings, because
"the artifact is older than the source" is one sentence that cannot rot.

Both were mutation-tested the way selfcheck.mjs asks: a var nothing defines
turns tokens.mjs red, touching a source file turns shipped.mjs red, and a
PERF_SRC that points nowhere fails loudly instead of passing quietly.
2026-08-26 18:14:52 -07:00
yhh 2c844876a6 feat(studio): the window opens on itself, and a listed change opens where it is listed
Six things, and four of them were defects rather than taste.

The window opened on a blank page. index.html now paints a boot screen before
the bundle it would otherwise wait for exists: the wordmark written on letter by
letter in gilt and settling to ink, the sky the theme packs draw, and one line
saying the kernel is being reached. main.tsx hands the window over when the
intro has finished rather than after a counted delay, so a fast kernel does not
flash it and a slow one is what it is for. It carries its own palette because
tokens.css is a bundled import that does not exist yet at that point.

Settings becomes a sheet over the workspace instead of a second full-screen
window. That removes the duplicate: .prefs-hd used to be a title bar too, with
its own drag region, its own traffic-light inset and its own window controls.
The window frame is .chrome and only .chrome now, and the veil starts below it,
so the window can still be dragged, minimised and closed while settings is
open — a modal that traps the window buttons is not a modal. Its contents list
grows the design's marks and groups, and reports what each section is worth:
storage in bytes, usage in tokens over the window Usage itself opens on, the
build running.

The segmented control was a full-width strip of underlined tabs at all sixteen
call sites. It is an inset track with the selected cell lifted out of it, which
is a control's size rather than a navigation bar's, and section headings shrink
to overlines so the rows under them carry the weight.

Then the defects:

- A theme's picture is body::before at z-index -2, and it is visible only
  because an uncoloured html hands the canvas to body's background. The boot
  screen's stylesheet had coloured html, which takes the canvas over and
  demotes body's to an ordinary element paint — landing on top of the picture.
  A wallpaper simply stopped appearing. boot.test.ts fails any rule that puts a
  background on html or :root, because nothing else was watching that file.
- The cost panel's sparkline was a black rectangle. Spark's fill and stroke
  rules were written under .rate, the throughput row's scope, so the cost
  panel's copy matched none of them and an SVG with no fill declared is filled
  black. The colours belong to the mark; only the throughput row's geometry
  stays scoped. The panel's wrapper stops sharing the component's own class,
  which is what let a wrapper's layout land on the drawing inside it.
- A picker's menu jumped to the bottom of the window before fading out. Its
  anchor name hangs on [aria-expanded="true"], which flips the instant the menu
  is asked to close, while the menu is still on screen; with no anchor left to
  resolve, an anchor() without a fallback invalidates the whole declaration.
  The fallback is the same 100% the non-anchored rules have always used.
- Every view swap cross-faded a snapshot of the entire document. Only the
  swapped element carries a view-transition-name, which swap.ts says in a
  comment, but the root kept its own default name — so closing settings dipped
  the whole window and brought it back. :root opts out.

The preview itself is a sheet in the same family, portalled to the body because
under a wallpaper the side column carries a backdrop-filter and that makes it
the containing block for anything fixed inside it. Rows in the changes list
become buttons carrying git's own letter, coloured by what it means.

--fg was read six times in the usage panel and has never been defined, so those
declarations were dropped and the colour fell back to whatever was inherited.
It is --text. bytes() ran to MB and stopped, which is how a 9 GB total read as
"9318.4 MB"; it runs to TB, and Storage stops carrying a second copy of the
rule that i18n/format exists to hold.
2026-08-26 18:14:52 -07:00
yhh 6eec7b6afb feat(serve): a listed working-tree change answers with the diff behind it
The rail lists what the tree differs by, and until now that was all it could
say: a path and a porcelain letter. What the file actually differs by lived
only in the tool events of the turn that wrote it, so a change made before this
session — or by a shell command no event describes — could be named and not
read.

gitstatus.Diff answers for one path. It measures against HEAD so a staged edit
and an unstaged one read as the single change they are to a person. An
untracked file has nothing in HEAD to compare with and is diffed against the
null device instead: that is the only way git prints it without first writing
to the index, which a read must not do.

The path arrives from a client, so safeRel keeps three shapes away from git —
outside the tree, absolute, and readable as an option — and every invocation
puts it after "--" as well. ErrPathOutsideTree is a sentinel because the caller
has to tell a bad request from a broken repository; over HTTP they carry
changes.path_outside_tree and changes.diff_failed, and the frontend has words
for both.

root goes through gitcmd's dir parameter rather than an "-C" argument. gitcmd
hardens a subcommand it can see at args[0], and --no-ext-diff/--no-textconv are
exactly the flags a diff against someone else's repository needs: a repository
config can name a program git then runs during an ordinary read.

The desktop shell needed telling too. Its route table lists families rather
than leaves, so /changes/ joins the prefixes — without it the shell answered
/changes/diff with index.html and the preview would have failed on a body that
parses as HTML. TestEveryRouteTheKernelRegistersIsRouted caught that before it
shipped, which is what it is for.

routes() moves to routes.go. serve.go sat exactly on its file-size budget, so
one more registration line broke it, and the budget is not there to be widened:
the route table is every path this server answers, which is a subject of its
own rather than a section of the file that assembles the server. serve.go drops
from 1459 lines to 1365.
2026-08-26 18:14:52 -07:00
esengine be6feb7cd2 docs(studio): release notes for 2.8.0 2026-08-26 07:53:06 -07:00
esengine 747b9fe4ee fix(covergate): a package pattern is slash-separated on every host
packagesFor took the directory of a declared file with filepath.Dir, which
on Windows answers `internal\control`. That is not a pattern go test can
match, and it sorted the list wrong on the way out, because a backslash
outranks a slash — one root cause, two symptoms in the same assertion.

The gate itself runs on ubuntu, so nothing it gates was affected; its test
runs on the whole matrix, and has failed windows-latest since the gate
landed. A declared glob and a go package pattern are both slash-separated
whatever the host is, so both reads move to path.
2026-08-26 07:52:58 -07:00
esengine 03de82ae03 fix(windows): the taskbar identity is applied again
Windows groups a taskbar button and routes a toast by the process's
AppUserModelID, and the Start Menu shortcut has to carry the same one or
the notification is dropped. The two calls that set both lived in the
retired shell. When it went they lost their caller, and the sweep that
deletes unreferenced exports took them — leaving internal/notify sending
toasts under an identity nothing claims, and six unused findings on the
GOOS=windows lint leg, which the sweep's own host-GOOS run never saw.

Restored and called from the window's startup, before the first window
exists, which is when Windows reads it. The call site carries no build
tag: identity_other.go already answers for every other platform.
2026-08-26 07:36:18 -07:00
esengine eeb245f6f2 fix(serve): a rebuilt runtime emits into the pane's own sink
A host decorates its pane's sink once — the status icon's fold, desktop
notifications, usage counters — and builds the controller on it. That
decoration lived only at the build call site, so every path that rebuilds
the controller wired the replacement to the bare broadcaster and dropped
it: a model or effort switch, an extension reload, a workspace change,
and saving a provider credential, which is the first thing a new install
does.

Nothing corrected it afterwards. A switch is refused while a turn runs,
so the last event those observers saw was the previous TurnDone, and they
stayed on it for the pane's whole life. The tray reported "nothing
running" through a fourteen-minute turn.

The Server now carries the sink its controller emits into, so the thing
that rebuilds owns it rather than the call site that happened to build
first. build and rebuildWith move beside the options they read; the file
they left is over its ceiling and had no room for the addition.

The test fails on the old code for both rebuild paths with the reported
symptom: the pane's decoration sees none of the replacement's events.

Reported in #9448. The paint ordering fixed in db030ad0 is a real defect
but needs two panes changing at once, and is not what that report shows.
2026-08-26 07:35:40 -07:00
esengine e3031b5fd7 fix(benchmarks): two generated graders reach the same verdict off POSIX
Both have failed every run of CI's windows-latest job since the corpus
landed, and neither is a flake.

gen-topo-cycle timed each call with signal.alarm. Windows Python has no
SIGALRM, so the grader raised while installing the handler, before it
graded anything. A daemon worker thread keeps the semantics exactly —
_Timeout on a hang, the original exception re-raised, so the cyclic case
still reads ValueError — and cannot hold the interpreter open at exit.

gen-csv-quotes compared cli.py's stdout against an expected string.
Windows Python writes CRLF in text mode, so that comparison could never
hold; the carriage returns come off first, because the grader checks the
fields and not the host's line endings. Its mktemp template also read
TMPDIR, which under Git Bash is a Windows path — the rest of the corpus
asks for a bare mktemp -d, and now so does this one.
2026-08-26 07:35:14 -07:00
esengine 42bba371f9 fix(repolint): the wire-parity reader takes the linter's own advice
The modernize pass reports two findings on this file, and they fail both
`make lint` and CI's lint job. A strings.Index used to slice a prefix off
is strings.Cut, and ranging over a Split is SplitSeq.
2026-08-26 07:35:06 -07:00
yhh db030ad097 fix(desktop): the status icon ends up on the fold the tracker actually holds
Two panes changing at once take their folds under the lock and report outside
it, so the two reports could reach the icon in the opposite order. The one that
lost left the icon describing a state the tracker had already moved past, and
nothing corrected it: the fold reports only what changed, so a wrong paint
latched until the next real change. A pane running a fifteen-minute turn is
fifteen minutes of a tray that says nothing is running.

Painting is now serialised and describes the fold as it stands when it paints,
so a report that lost the race repaints the truth instead of overwriting it.

The test fails on the old code with the reported symptom exactly: the icon
shows Working:0 while the tracker holds Working:1.

Reported in #9448.
2026-08-26 06:05:46 -07:00
yhh 4d320f7cd7 fix(agent): a change stays in review's scope until a review has covered it
The review gate read one index for two questions — what the change set is,
and how fresh a review of it must be — so a later harmless write moved the
window past everything before it. A sensitive edit followed by a README write
scored Low and dropped the whole structured-review demand.

UnreviewedMutationBaseline now answers the first question and the latest
mutation still answers the second. It advances per review kind, so an accepted
review does not discharge security_review's obligation with it.

Alongside, the parts of that gate that promised more than they did:

- A blocking verdict is honored at every role setting, and review subagents
  submit the typed report everywhere. A verdict a caller must act on carries
  an identity, not a sentence. The nudge runs everywhere too; the hard failure
  stays in Delivery, since outside it no report is no block either way.
- Medium no longer demands a structured review "or" host-proven
  self-inspection. That was no alternative — the reader there is the author —
  so the cheap side was always taken and the demand never bound. The real
  requirement inside it moves to the self-inspection gate and grows teeth:
  inspect every changed path, fresh per path, not one of them.
- warn findings were collected into a slice nothing read. They now reach the
  user, and they are collected from any review the turn ran rather than only
  from one the gate asked for.
- verificationGapResult.advisory carried no text and reached nobody while two
  comments called it reported. The waiver it stood for is right and stays; the
  flag wearing a message's type is gone.

The review and security-review bodies stop hardcoding git: fileutil owns one
declared store table, environment reads it, and a skill subagent's prompt now
carries the workspace fact its parent always had. They also drop the ~12-call
self-cap, which was well under the host's real budget, and split the burden of
proof — adversarial about what must be read, conservative about what may be
reported. A pass is a negative claim and owes what any other does.

complete_step: manual evidence no longer stands alone in a turn that already
has receipts. The evidence ablation arm reaches the tool schema, so the two
arms stop paying the same prompt and the A/B can answer for the contract
rather than only for the gate.
2026-08-26 05:55:04 -07:00
yhh 1a64030c11 feat(repolint): the desktop's copy of a wire type is held to the kernel's
agentgraph's own account of why it exists is that every consumer folds the same
deltas, so the graph a headless run reports is the graph the desktop draws. The
desktop's fold is a second, hand-written copy — the type and the merge both —
and nothing checked the two carried the same fields. Adding one field to the
kernel this week meant remembering to add it in two more places by hand. The
next person will not remember, and neither toolchain will say so: they are
compiled separately and meet only at runtime, over JSON.

wire-parity reads the Go struct's json tags against the TS interface's
declarations and fails either direction — a field one side sends and the other
cannot read, and one it reads that nothing sends. A field the encoder skips is
not on the wire and is not demanded, so an internal field costs nothing.

The pairs are declared rather than inferred, for the reason the sensitive paths
are: no spelling tells a mirrored contract from a struct that merely carries
json tags. Only the graph is declared today, because it is the one that claims
both sides fold identically.
2026-08-26 04:23:25 -07:00
yhh f099a7624e fix(studio): the desktop fold learns the field the kernel just published
agentgraph's own account of why it exists is that every consumer folds deltas
with the same Apply, so the graph a headless run reports is the graph the desktop
draws. The desktop's fold is a second, hand-written copy of it, and nothing
checks the two carry the same fields — a wait cause published by the kernel and
unknown here is a node the picture cannot say anything about.

The type is what holds it: dropping it fails the typecheck through this fold and
its test. The merge line is consistency with every other field rather than a
defence — an absent key already survives the spread.

Rendering is left alone. Every site that would show it, and the strings it would
need, are mid-change in the working tree, and this is one row away whenever that
lands.
2026-08-26 03:51:57 -07:00
yhh 2179775d90 feat(agent): a queued node says which constraint is holding it
Queued was one word for three constraints with two remedies. Two are session
settings — the total sub-agent ceiling and the concurrent-writer ceiling, which
binds a writer while total capacity is still free — and the third is a write
path someone else already holds, which no setting releases: only disjoint paths,
or an edge that orders the pair. The scheduler was the only thing in a position
to tell them apart, and it answered in a sentence built for one caller's error
message, so nothing downstream could.

WaitCause joins the graph's vocabulary beside NodeState and Grant, canStartLocked
returns it instead of prose, and an acquire that has to wait reports it once —
after the unlock, because the callback reaches a sink the scheduler must not be
holding its mutex for. The cause survives the run starting, the way the stamps
do: the state says running now, this says what it waited on.

The fan-out price splits with it. One figure summed every wait between ready and
running while its name said the concurrency ceiling caused it, so a member held
behind a claim was quoted back as a reason to raise a ceiling that would not have
moved it. Two figures now, because there are two answers.

Both fan-outs take the same pair of hooks from one place: the two moments only
the scheduler can report are published by one thing rather than copied into each
producer.
2026-08-26 03:48:45 -07:00
yhh 875a1c1fbc fix(agent): a parallel_tasks node says what it runs as
parallel_tasks takes a model and an effort per task, so two members of one group
are not necessarily the same worker. The graph drew them as if they were: a
fleet node carried the profile, model, effort and grant its item ran under, and
these carried a label and nothing else. A reader asking why one arm of a fan-out
cost more than another had nothing on the picture to answer with — the tool
offers the distinction and the graph erased it.

The identity is resolved once, in the loop that was already building the labels,
rather than again inside each item's goroutine.
2026-08-26 03:23:49 -07:00
yhh ba84b02b45 fix(control): the two prompts that continue a turn name the same doors
A run can be told to finish its list by two host prompts: the readiness
continuation, and the Goal FSM's "signaled complete but issues remain". Only the
first was ever taught which actions the completion guard lets through. The Goal
path still ended with "use todo_write/complete_step to mark done" — the one
action the guard refuses — so a run reading it beside the requirement it embeds
was handed the open door and the shut one in a single message, and heard about
neither turn exit.

Both now append one string. Owning it once is the point: the failure here was
two copies drifting apart, and a third copy is what let them.

That third copy was the requirement's own. Every path that shows a requirement
to a model goes through one of these two prompts, so its ways-out sentences were
read twice, in two wordings, inside one message. Its other readers — the ACP
turn outcome, the recovery card — are host- and user-facing, and were being
handed instructions written for a model.
2026-08-26 03:18:00 -07:00
yhh 345e9dbff4 fix(agent): a field of the wrong kind is answered in the caller's vocabulary
The argument contract read which fields a call supplied and never what it put in
them. A field with the right name and the wrong kind was therefore neither
unknown nor missing: it passed every check the host makes, and the answer came
from Go's unmarshaler — "cannot unmarshal string into Go struct field .evidence
of type []agent.blockedEvidence". That names the host's types rather than the
caller's mistake, and it costs the round trip that discovers it. A live run
spent one that way on conclude_blocked before it even reached the two it spent
on the field names and the evidence itself.

The schema already declares each property's kind and the arguments already carry
one, so comparing them is the same structural read the contract was doing on
names. A mismatch now refuses the call before permission is consulted, for the
reason the missing-field case already refuses: execution was never possible.

A null is not a mismatch, and a property whose schema declares no type leaves
the question unanswered rather than broken — the contract may only report what
the schema actually said. The hint drops "it accepts ..." for a pure mistype,
because the field is accepted and listing the accepted names answers a question
nobody asked.

The contract is read after proxy resolution, so a tool reached through
use_capability is compared against its own schema and refused the same way; a
test pins that, because reading the call order is how you get it backwards. An
on-demand MCP tool declares no properties and the contract stays silent on it. A
connected one is compared against its server's schema — the same trust the host
already places there by refusing a call that omits a field it marks required.
2026-08-26 03:18:00 -07:00
yhh 1e98fa29f0 fix(control): a turn stopped on a question is not a turn that owes work
addTurnExitTools registers the two ways a turn ends on the model's terms rather
than the host's: ask for a decision only the user can make, conclude_blocked for
a task that cannot be done as specified. The host's own account of the ways out
named only the second. So a turn that ended by putting a question to the user
was continued straight past it, and the run — told in the host's voice that
there were exactly three doors and that none of them was "wait" — answered the
question itself and said so in the transcript.

Naming ask closes it, in the requirement and in the continuation alike, with the
rationalisation it has to beat spelled out: not having heard back is not an
answer to assume one from.

The escape that used to exist was an accident. The stall counter compared the
gap against the best round so far, and a run waiting on an answer could not
shrink it, so two rounds handed control back. Scoring the continuation against
the previous round's signature instead removed that side effect without
replacing it with a deliberate one.

Nothing here reads the wording of what the model said. The signal is the
declared turn-exit set, which the host was already free to name and did not.
2026-08-26 01:10:00 -07:00
yhh 523791aff3 feat(agent): a contended slot goes to what is holding up the most work
The fleet plan proved a dependency DAG and published it, and the scheduler that
decides which item actually runs never saw it: an AcquireRequest carried a write
claim and a label and no position in the graph at all. So a slot went to whoever
queued first, which is how the head of the longest chain ends up behind leaves
that unblock nothing — and that wait lands on the run's makespan one for one.
It is the loss fanoutMetricsOf already prices as wall above the critical path:
measured every run, acted on by nothing.

The plan now measures each item's rank, the longest chain of dependents below
it, and carries it to the acquire, where the pump drains waiters heaviest first
and keeps arrival order within equal weight. That covers every grant after the
session's slots are full, which on a fan-out wider than the ceiling is nearly
all of them.

It does not cover the first fill, and sorting the launch loop does not either.
Which of a burst of goroutines reaches the scheduler first is the Go runtime's
decision: launching heaviest-first captured 41.8% of the ideal first-fill weight
against declaration order's 51.9% at a ceiling of two, and tied at one and three
— never better, sometimes worse, because the runtime favours the goroutine
created last. The loop is left alone and says why.

A run also queues before it builds rather than after. It used to construct the
whole child — registry, runtime, and the transcript that holds a store lease —
and only then join the line, so a fan-out wider than the session ceiling held
one open per item it had not started, and every millisecond of that setup was
billed to a concurrency ceiling that had not caused it. Foreground runs hold the
slot from before the transcript exists; background ones still return a job id
first and queue inside the job. Deriving the request moves to slot.go, the file
that owns the acquire.
2026-08-26 01:10:00 -07:00
yhh dc96f21a86 feat(benchmarks): the concurrency claim gets an arm that can measure it
Delegation makes three claims and upstream-edge can only settle one: both of
its tasks are strict chains, so their speed-up is 1.00x by construction and no
scheduler decision is observable in them. fanout-width fixes a shape whose
three research items share nothing, which leaves the session concurrency
ceiling as the only thing deciding whether they overlap — so the serialised arm
needs no ablation flag, only max_subagent_concurrency = 1.

Each subsystem retires nine of ten modules and names the live one in entry.py,
so a grep over the markers returns ten candidates and answers nothing.
2026-08-25 19:09:41 -07:00
yhh 8fd25c9ada fix(control): a delegation receipt is not lost by the wrapper that watches two events
inboxEventSink hand-forwarded seven optional sink capabilities and missed
three, so every child run's receipt died one wrapper below the sink that
accounts for it. A benchmark then reported four fleet members as a task one
agent had solved by itself, and the whole delegation section rendered empty
while its owning tests stayed green — which is the failure AuditForwarder's own
doc comment already described. Embedding it is that comment's remedy: a channel
added there now reaches this wrapper without editing it.
2026-08-25 19:09:41 -07:00
yhh e749f1c8c1 feat(cli): a fan-out is priced, not only counted
The graph already carried every stamp a benchmark needs and nothing folded
them, so delegation was reported as a count of children and never as what the
shape bought. Work against wall is the speed-up; the critical path is the floor
the declared dependencies impose, and wall above it is scheduling loss; slot
wait is what the concurrency ceiling cost members with nothing left to wait for
— the one wait no edge on the graph can draw.

The effect test now pins the stamps themselves. Without them every figure here
folds to zero, which reads as a fan-out that took no time rather than as a
measurement that never happened.
2026-08-25 19:09:41 -07:00
yhh 41da7e48be fix(serve): the run graph survives the window that reopens
The pane already replayed the trajectory into the graph reducer, but the wire
log dropped every graph_delta on the way to disk, so a reopened window drew a
run that had waited on nothing. A dependency and an adopted answer exist
nowhere else in the stream: unlike a tool mark, they cannot be re-derived.
2026-08-25 19:09:41 -07:00
yhh efb8c387a2 fix(studio): a crash the window did not survive leaves something to read
Studio never called crashreport at all — only the CLI did — so not even a Go
panic was recorded. That is the smaller half. Every Studio crash reported so
far has been a fatal signal inside GTK or WebKit, which no recover can reach:
the runtime writes that trace straight to the descriptor, so a tee on
os.Stderr would keep every log line and lose the only one worth keeping.

So stderr is redirected, not wrapped. The window opens a log under
<home>/logs and dups it onto the descriptor, keeping a copy of the terminal
first when there was one, so running Studio from a shell still prints. One
log per launch, newest few kept: a crash is reported after a relaunch, never
during the run that produced it.

Linux uses Dup3 rather than Dup2 — linux/arm64 has no dup2 syscall at all.
Windows says it cannot instead of pretending: it resolves the handle its
crash writer uses once, at start, so moving os.Stderr afterwards would move
the window's own logs and not the trace. There the log still gets those.

crashreport can now name the host it came from. sanitizeReport overwrote
Source with "cli.go" unconditionally, so a Studio report would have arrived
claiming the CLI and sent whoever read it to the wrong code. The set is
closed rather than free-form, and the CLI's own reports are unchanged.

Motivated by #9408, where the crash is inside cgo and the Go traceback stops
at gtk_main(). The only trace anyone has is there because the reporter
happened to launch from a terminal.
2026-08-25 19:09:41 -07:00
esengine 7a48ab5bb3 refactor(control): split the Controller by field lifetime
A census of the 78 fields, by where each is written rather than by reading
the names: 37 are bound once in New and never reassigned, and the rest change
while the session runs. Both kinds sat in one list, so c.policy and c.turn
read alike at every call site and only one of them can change under you —
which is the question anyone reaching for c.mu has to answer first.

The 37 move into an embedded controllerDeps. Embedded because field promotion
keeps all ~720 c.field reads resolving unchanged: the point is to make the
lifetime legible at the declaration, not to rename the package. Controller's
own field list drops to 42.

Two fields that qualified by the write test stayed out on purpose. commands
is an atomic.Pointer and externalFolderToolRefs sits with the mutex-guarded
map it belongs to; filing either under "never rebinds" would read as "not
concurrent", which is the opposite of true.

repolint reclaims struct-state 5, file-size 123, function-size 5 and essay 6
— the scalar debt recorded against controller.go was exactly this mixing.
Tests, vet, and -race on the package all pass unchanged.
2026-08-25 18:43:45 -07:00
esengine 7d17aa7c1f feat(studio): memory revision history is reachable
The memory panel has always told the reader that saving "records a new
revision; the old one stays". Nothing in the UI could show that history or
step back into it: GET /memory/revisions and POST /memory/restore were both
served, both had Controller port methods behind them, and the frontend never
named either — the promise was there and unverifiable.

An entry past its first revision gets a history control; opening it lists the
revisions behind the current one with their dates and bodies, each restorable.
Restoring appends a revision rather than rewinding to one, which the panel
says, because a restore that dropped later revisions would destroy the very
version a reader opened the history to compare against.

The fixture kept only the latest revision, so a panel driven by it would have
shown an empty history and proved nothing; it now keeps what a save replaced,
the way the store does.

Verified in the running Studio through CDP: editing an entry produces
revision 2, the history lists revision 1 with its original body, and
restoring it leaves the entry on revision 3 carrying revision 1's text with
both older revisions still listed.
2026-08-25 18:33:43 -07:00
esengine 86daa61e9d feat(covergate): the declared-sensitive paths hold their coverage
REASONIX.md calls these paths the ones where a wrong change is "a hole in a
boundary". Measured, they were the least-tested part of the kernel rather
than the best: shellsafe 59.3%, shellparse 63.6%, sandbox 69.4%, netclient
47.2%, against a tree median of 76.5%. The code deciding what the agent may
execute and reach had the weakest evidence that it works.

The gate reads the same `sensitive:` bullets the runtime reads for its
review requirement, so a path is declared once and gets both effects — there
is no second list to fall out of sync. Floors are per declared path and
measured from the coverage profile per file, not per package, because
`internal/control/approval.go` is sensitive while the package around it is
not; measured on its own it is 93.9%, which no package number would show.

Floors ratchet in the opposite direction to repolint's budgets and for the
same reason: raising one is free, lowering one needs -allow-drop and a
reason in the PR. They record what is there today rather than an aspiration,
so the gate is green on arrival and only fails on a regression.

Verified by removing one shellsafe test file: the gate reports 47.3% against
its 59.3% floor. Adding an unrelated `sensitive:` line puts that path under
the gate with no other change.
2026-08-25 18:22:23 -07:00
esengine e4a6dee1a3 refactor(kernel): delete the exported functions nothing references
Two fixes and one sweep, all from the same audit.

The process history catalog was never closed. boot's cleanup releases LSP,
plugin hosts, the runtime scope and session temp; the catalog's own
Flush/Close had lived in the retired shell and were left to two test files.
It is process-wide, shared by every pane and workspace, so it closes at the
process boundary next to the usage catalogs, not with any one controller.
Queued index writes are drained first — the reopen after an unclean exit
re-scans every root instead.

The sweep removes 57 exported functions with no reference anywhere in the
tree, tests included, plus the private helpers that fell dead behind them.
Iterated to a fixpoint: deleting a function exposes the next one, and both
the orphan rule and golangci's unused ran again after each round until
neither reported anything.

The rule learned two things from being wrong here. Methods are now out of
scope: chatTUI.Init is bubbletea's tea.Model, no file in the tree names it,
and proving that needs type information an AST pass does not have — the
first attempt at this sweep deleted it. And an orphan tests still reference
is reported differently from one nothing references, because they need
opposite fixes: repair.PrepareFileUpdate is a cross-package fixture eight
test files build state with, and deleting it was the second thing that
attempt got wrong. Only the unambiguous half is deleted here; the 124
remaining stay recorded.
2026-08-25 18:12:18 -07:00
esengine 93da1a1d7e feat(repolint): an exported kernel API with no caller fails the gate
The last-known-good snapshot died because deleting one frontend left its
callee behind with the implementation, the tests, and the doc comments all
intact. Nothing in CI reads "has a caller", so the tree stayed green for
eight days while a recovery path read a file no longer written.

The orphan rule counts callers across the whole tree, which is the part a
single-module analysis cannot do: desktop/ is its own module and a real
consumer of internal/, so a scan that stopped at the root module would call
its APIs dead. Two things deliberately do not count as callers — tests,
which are what keep an orphan warm and green, and doc comments, which are
what make one look wired. Both are checked by tests here, because both were
true of RecordHealthyConfig.

Test-support packages are exempt: their exports cannot move into _test.go,
so having no production caller is their normal state.

328 existing orphans are recorded as the ratchet baseline, not fixed here.
Verified against the original accident: reverting the fix from the previous
commit trips the gate on RecordHealthyConfig by name.
2026-08-25 17:55:18 -07:00
esengine 3f2a7ba84a fix(config): the last-known-good snapshot has a writer again
Recovery reads a snapshot nothing had written since the Wails shell was
deleted. That shell held the only two RecordHealthyConfig calls, so from
908bc3ec0 on the read paths in config/load.go and config/unparsed.go kept
restoring whatever config was current that day. On this machine the file
was 16 days and 161 diff lines stale, and `doctor repair --apply` would
have written it back over the live config without saying so.

The shell proved health with "the window became visible", a signal only
Wails had. A successful assembly proves the same thing for every frontend,
so the record rides BuildRuntime, which reload goes through too, and CLI /
serve / Studio inherit it instead of each re-wiring it. Options.Version is
the host declaring a real installation; embedded and test assemblies leave
it empty and write nothing. The writer is already idempotent — unchanged
config hashes to the existing snapshot — so every start can call it.

Both recovery paths now date what they are about to trust: the load warning
carries the snapshot's age, and doctor repair prints it before --apply
rather than after.

Options moves to its own file, which is what pays for the new field: boot.go
drops 128 lines and the tree reclaims 176 file-size, 21 function-size, 15
essay and 3 complexity of budget.

Verified against a real binary: assembly writes the snapshot and stamps the
ldflags version, a second run adds none, and a broken config reports
"--apply would restore a snapshot 16 days old".
2026-08-25 17:49:38 -07:00
esengine 45397daadb chore(deps): gorilla/websocket 1.5.0 to 1.5.3
govulncheck reported seven reachable call paths into the 1.5.0 advisories —
the feishu adapter's socket, and two readers that reach a compressed frame
through io.ReadAll. It now finds no vulnerability this code calls.
2026-08-25 17:13:02 -07:00
esengine efb3488aab fix(control): the test sink that collects events survives a second emitter
collectSink appended to an unsynchronised slice and handed the slice itself
back. That held while a turn was the only thing emitting. It stopped holding
when the inbox gained a change listener: the store notifies from its own
goroutine, so six tests began failing under -race — both stacks inside the
helper, one appending while the other read.

It appends under a lock and returns a copy taken under the same one. The race
was never in the controller; the harness had no way to say so.
2026-08-25 17:12:53 -07:00
esengine 9b7a573ac3 docs(studio): release notes for 2.7.0 2026-08-25 10:10:00 -07:00
esengine 4ea1f1768f feat(e2ebench): the report separates conversation spend from auxiliary calls
One cost line mixed the rounds that carry the session with the single-purpose
calls beside them — titles, digests, classification. They scale differently, so
a run that got dearer said nothing about which half moved.

The split is by usage source and named the other way round: the conversation
sources are listed, so a source added later counts as auxiliary instead of
falling out of the split with nobody noticing.
2026-08-25 10:06:02 -07:00
esengine 19107fcdeb feat(benchmarks): a generated corpus, held to both halves of its grader
Failing the pristine seed is only half of an authored task. A grader that can
never pass is indistinguishable from one with no solution: every attempt scores
zero, and a corpus of those teaches that nothing is ever accepted. Tasks that
ship a reference solution are now checked against it, and a generated task that
cannot satisfy its own grader is deleted rather than patched.

The generated tasks live apart from benchmarks/e2e on purpose. That suite is
the instrument every cross-version comparison is read against; mixing new
tasks into it would change what those numbers mean.
2026-08-25 10:05:52 -07:00
esengine 8e7c87c8b3 feat(e2ebench): sft mode exports the runs the grader accepted
Recorded runs are only training data once someone decides which ones were any
good, and the run report already holds that verdict. sft mode reads the two
together: the report decides what is exported, the trajectory's run header
supplies the prefix each sample was produced under.

A run without a header is skipped rather than exported against a guess at what
its prompt and tools were.
2026-08-25 10:05:41 -07:00
esengine ac48205da0 feat(trajectory): a run records the prefix its rounds were sampled against
A trajectory held the event stream and nothing from the request side, so a
reader could see what the model said and never what it was asked — the system
prompt and the tool schemas the rounds were sampled against were simply gone
by the time anyone read the file.

The header carries both, plus the hashes usage events already report, so a
reader can prove the pair belongs to the rounds that follow instead of
assuming it from the file it was found in. It is written by the frontend that
owns the recorder rather than forwarded through the sink chain, which keeps it
out of the optional-capability contract every wrapper would otherwise carry.

NormalizedToolSchemas exports the exact bytes ToolsHash covers, so the hash is
recomputed from what was stored rather than from a second serialization.
bindRunSession collects what a headless run does to attach to its session,
which had grown into four steps inline in runAgent.
2026-08-25 10:05:30 -07:00
esengine 522eb69230 feat(telemetry): a metric is a count, not an install id
Opt-in metrics carried the anonymous install id so the dashboard could show a
deduplicated daily user per signal. Keeping that answer meant keeping a row per
install per signal per day — ~28M of them — and the 30-day COUNT(DISTINCT
install_id) over that table was the database's entire read bill: the primary
key leads with `date`, so each query could prune only the window and scanned
the whole table, eight signals an hour, ~5.4B rows a day for one module.

The client stops sending the id, the worker stops writing and creating the
tables, the hourly rollup that existed only to make that query survivable is
gone, and the dashboard's users/opens toggle goes with it. The aggregate
tables keep the same signals as event counts in ~180k rows.

migrate-drop-metric-users.sql drops the four retired tables. Deploy the worker
first — it stops creating them at runtime, and a DROP before that is rebuilt on
the next request.
2026-08-25 10:05:18 -07:00
esengine 3dfb0bd6fd fix(bash): a reference bash cannot see says so, instead of "No such file"
Dropping a folder from outside the workspace mints a token and registers it
with the read tools' resolver. That is where the mapping to a real directory
lives, and the grant is read-only — handing bash the real path instead would be
a writable way into it. So the token is not a path, and bash's answer for one is
the shell's own: "No such file or directory".

A model that was handed the token in the same turn believes that. This session
did: it ran `ls` on the token, then `find` over the whole home directory, and
concluded the reference was virtual and the file did not exist — while the file
sat on the Desktop the entire time, reachable by read_file all along.

Bash now recognises a token before running and says what it is and which tools
resolve it. Recognition, not a guess about what a word might mean: the resolver
is asked, and its keys were minted by the host.

The four preflight refusals repeated the same four assignments before returning,
so they are one call now — adding a fifth check left the function shorter.
2026-08-25 09:41:08 -07:00
esengine 77d844f40f fix(serve): a new conversation keeps the approval posture you set
The posture a pane opened in was read off whichever runtime happened to be
first in the hub's order. Closing the conversation the person had set to auto
or yolo therefore reset the next one to ask — an approval prompt on a tool
they had already waved through, with nothing on screen saying why — and with
every pane closed there was nobody left to read it from at all. The same line
dereferenced Server on a remote pane, which does not have one.

The hub holds the stance instead: seeded from the runtime the host adopts, so
it is the launch setting until someone changes it, and written by the composer
switch, which already persisted the same choice to config for the next launch.
It no longer depends on which panes are still open.

The approval endpoints move next to that setter, where the rest of the
settings surface lives.
2026-08-25 09:07:44 -07:00
esengine 7d29cf4925 feat(studio): the status icon is the composer's R, in one of three colours
The icon drew a rounded square with a notch and a corner badge — a mark that
appears nowhere else in the product, so the tray was the one place Reasonix
was a different logo.

It is now RMark.tsx's three paths, carried over in the 16-unit space the SVG
draws them in and rasterized against a distance field: reading a stroke as a
capsule is what gives the round caps and joins without drawing either.

Mood is colour and nothing else, each hue the one meaning the palette already
assigns it — ghost is neutral, net is running on its own, accent is needs you.
The lightness belongs to neither theme, because a notification area is a
background this process cannot ask about: all three clear 3:1 against white
and against near-black.

The tests hold the two drawings together, so a path or a stroke weight that
moves in the frontend fails here instead of leaving the icon on the old glyph.
2026-08-25 09:07:28 -07:00
esengine c947da6b23 fix(studio): a second launch raises the window you already have
Studio never carried a SingleInstanceLock, so every double-click of the
launcher started another process over the same data home — several windows,
each binding session leases the others were already holding.

The lock is keyed to the data home rather than the executable: an installed
build, a portable one and a canary all write the same sessions, so they have
to find each other, while an explicit REASONIX_HOME stays its own instance.
The prefix is Studio's own bundle identifier, so this never answers the 1.x
desktop's lock.

REASONIX_DEV still waives it, and run-studio.sh now passes that through
`open --env` — LaunchServices does not inherit the shell's environment, so
without it a dev launch would be handed to an installed window.
2026-08-25 09:07:18 -07:00
esengine 35bc86c319 fix(serve): a dropped document is not a picture
Closes #9412.

`droppedRef.Image` carried `omitempty`, and false is a bool's zero value, so
the field left the JSON at precisely the value the composer needed: every file
that is not a picture answered with silence. The page read that silence as an
image — the check was `image === false`, and `undefined` is not `false` — and
drew an empty thumbnail labelled "image" over a .md file.

POST /attachments never had this: it writes its answer through a map, which
omitempty does not touch. Only a path dropped onto the window went wrong.

Both sides now state it. The field is always present, and the composer asks
whether it is an image rather than whether it was denied: a missing field falls
back to the file glyph, which still shows the name.
2026-08-25 08:56:42 -07:00
esengine 3fb8e43380 fix(studio): the picture behind the window is a picture, not a rumour
Two separate things were painting over a wallpaper nobody could see.

The rail: its 54px hit box is pointer-events:auto and sits over the transcript's
right edge, so any control that lays out to the right — the rewind entry is one
— lost its right half to it. The width is now a token both sides read, and the
transcript keeps clear of that column instead of fighting it for z-index, which
would only have traded a dead button for a dead stretch of track.

The wallpaper: three background rules were unconditional, and rail + panes +
side tile the window exactly, so the image was sealed under them. Two rules
written for it had therefore never once fired — .app cleared a path nothing
took, and .flow's 88% scrim, meant to catch prose over a photo, sat behind a
panel that was never transparent. With the panes clear they finally show, and
what they do is not what was wanted: the rails are down to a thin frosting,
the empty-page halo no longer tints itself from --surface (which reads as a
black fog in the dark theme), and prose carries its own shadow instead of
cutting a slab out of the picture.
2026-08-25 08:48:15 -07:00
esengine 2bfe378499 fix(studio): a rail you closed stays closed
Closing the metrics rail did not survive the window: nothing wrote the choice
down, so every launch opened it again. Two things had to change together —
the fold-away helper kept the wide-window value in a ref seeded to `true`, so
even within one session, narrowing the window and widening it back reopened a
rail the reader had shut.

The rail still opens by default. What is remembered is the reader closing it,
and only a deliberate close is written: folding away because the window ran out
of room leaves the wide-window choice alone.
2026-08-25 07:38:44 -07:00
esengine 4d1cd0842d fix(studio): a rebuild replaces the running Studio instead of stacking on it
The window process does not act on SIGTERM, so the pkill before `open` matched
the old instance and then left it running. Every `make studio` opened another
copy on top of the last, and they accumulated for as long as the machine stayed
up — two were live here, one of them a day old, before this was noticed.

Ask with SIGTERM, wait a beat, then insist.
2026-08-25 07:11:56 -07:00
yhh 9c76be7a23 feat(studio): the run graph is arranged, not just ranked
Ranks say which column a node sits in and nothing about its row, so the
order the deltas arrived in decided it. Wires crossed for no reason a
reader could act on, an edge spanning a column was drawn straight over
whatever cards stood in the middle, and the same run redrawn came out
different.

Layout moves to its own module so the arithmetic can be checked without
rendering anything, and gains Sugiyama's two missing steps:

- Ordering. Each column is swept by the median of its neighbours' rows,
  and only a sweep that lowered the crossing count is kept, so a round
  cannot make the picture worse.
- Waypoints. A long edge reserves a thin row in every rank it crosses,
  which is what keeps it off the cards it flies over.
- Columns are centred against the tallest rather than hung from the top,
  where a lone node read as a step that ran early instead of one that
  ran alone.

The panel also reads the kernel's new facts: a queued node says so
instead of sharing "not started" with a node nothing has reached yet,
the summary counts the two waits apart, and the detail strip carries
slot wait beside run time.

Folding is indexed rather than scanned. The graph is the one piece of
session state that only ever grows, so a findIndex per node and a scan
per edge made the cost of one delta the size of the whole run.
2026-08-25 04:15:53 -07:00
yhh 1511b331eb feat(agent): a run graph node says when it ran, and what it was waiting for
The graph declared StartedAt and EndedAt and no producer ever filled one,
so every consumer's timing fallback was dead code and a run's own order
survived only as the order its deltas happened to be published in.

Waiting for a slot and holding one were the same word besides. A fan-out
the session concurrency ceiling was throttling drew identically to one
where every item started at once — and which of the two it is decides
whether raising the ceiling would buy anything.

- agentgraph gains StateQueued and Node.QueuedAt. Terminal() had to learn
  the new state: a queued node reported as settled leaves a reader no
  longer waiting for the answer it is still going to get.
- SchedulerPolicy.OnStart fires once the slot is held, which is the only
  moment the wait can be read from. acquireSlot moves to its own file and
  becomes the single place that reports it, for both the foreground and
  the background path.
- fleet and parallel_tasks publish queued, then running, then settled.
  An item now settles the moment it settles rather than when its group
  ends: a long fan-out used to draw every finished worker as still
  running until its slowest sibling returned, which is the whole time
  anyone is watching it.
2026-08-25 04:15:06 -07:00
yhh 5ec7467396 fix(agent): a delegated run asks the person waiting for it, instead of itself
Reported as Studio never raising the ask card: the model kept answering with
"ask 无交互响应(fallback)" and telling the user to go fix it. It was reading
its own tool result back. A subagent's ask found no asker and returned the
headless fallback - the text written for autonomous runs, which says in as many
words that no interactive user answered.

The root executor has an asker: EnableInteractiveApproval wires the controller
in as one, and every interactive frontend calls it. A child never did.
newAgentServices binds each collaborator from Options and there is no Asker
among them, so SetAsker is the only way in and only the controller ever calls
it - on the executor and the planner it holds, never on anything they spawn.
The gap was silent because the fallback is a plausible answer rather than an
error.

The call context already carries the right answer and nobody was reading it.
RunSubAgentWithSession is the one construction boundary every child goes
through, and by the time it runs, the context says whether a question can be
put to anyone: a delegated run has a parent blocked on it and a person waiting,
so exactly one question can be outstanding; fleet items and parallel tasks
build their context with the asker dropped on purpose, because a question from
one of many has no reader who knows which asked it; a background job runs under
a job context with no call on it at all. Inheriting from the context therefore
needs no flag and no policy - the three cases already differ where it matters.

Both halves are tests now, because "silently answers itself" is what this class
of bug looks like and neither half fails loudly. The construction moved to its
own file rather than growing task.go, which is 1200 lines past its budget.
2026-08-25 02:18:20 -07:00
yhh d7b558fac2 fix(serve): a remote that refused says what it said, instead of "never arrived"
Reported as connecting to a Linux server failing with "这次请求没能送到内核
(HTTP 502)" and nothing else. The request had arrived. The kernel had
answered, at length, and two layers threw that answer away between it and the
window.

Opening a pane over there is two hops: attach the link, then ask the kernel now
running on the far side to open a runtime. The link half already codes its
failures - a changed host key is not a network hiccup, and identify() in the
desktop says so. The second half did not. farRequest returned fmt.Errorf with
the far side's status and body flattened into a sentence, writeErr found no
code on it and fell through to http.Error, and a sentence is exactly what the
repo's own rule says an error a caller must tell apart may not be. A dead link
and a kernel that answered with a refusal are different problems with different
next moves; only a code carries that.

They have codes now. A transport failure is remote.unreachable, the same
condition the proxy beneath already reports through a different door. A far
kernel that answered is remote.kernel_refused, carrying the host and whatever
it actually said - falling back to the status line only when the body was
empty. Three writeErr call sites pick both up without changing.

The other half is the hub port, which asked for JSON and discarded anything
that was not. SseHttp was fixed for this once - users reported
"/providers/edit: 500" - and the hub was left reading res.json(), so every
plain-text answer from every hub route reached the window as a status and a
path, which reason() then renders as the request never having been sent. It
reads the body as text once and parses optionally, the way its sibling does.
Only a genuinely empty body may now claim nothing came back.
2026-08-25 01:58:33 -07:00
yhh e88cf77ef0 fix(serve): a server waiting to be called is not a server that failed to connect
Reported as MCP not connecting automatically: twelve enabled servers, each
listing the tools it brought, every one of them reading "not connected" with a
Reconnect button beside it. Nothing was broken. That is what a healthy install
looks like, and the panel had no word for it.

Enabled config MCP is catalog-first by design. Boot registers each server's
cached toolset as placeholders and starts no process; the first real tool call
drives the handshake, and a fast server completes it in the same turn. So
host.Servers() is empty for all of them, every row falls through to the branch
for servers with no live connection, and that branch had two answers - off, or
idle - for three situations. The comment there already claimed to tell "switched
off" from "lazy and not needed yet"; the code did not.

The catalog answers it, not the host. A server whose tools are in this
session's registry can be called right now, whatever its process is doing, and
that is the question a status row is actually asking. MCPCatalogTools counts
the registry's MCP bindings in one pass - lazy placeholders included, because
they carry the server name like any other binding - and configuredState turns
that into standby, idle or disabled.

The panel says 待命 · 首次调用时启动 for standby and offers 立即连接 rather than
重连: nothing failed, so nothing is being retried. The mock's cache-hit fixture
moves with it, or the harness would keep showing the state this fixes.
2026-08-25 01:46:44 -07:00
yhh 3426f07329 fix(storage): a folder that already holds a root can be pointed at again
Reported as storage settings reset to C: after upgrading Studio, with the
original location then impossible to choose again because it "needs an empty
folder". Two defects, and the second is the one that traps a person: whatever
loses the pointer, the data is still sitting on the other drive and there is no
way to say so.

The reset is the installer. studio.nsi asks for admin and the finish page runs
Studio itself, so the app inherits the installer's token; on an account that
elevated by entering someone else's credentials, %APPDATA% then resolves to
that administrator's profile and the first launch after an update reads a
config.toml that never heard of the user's relocation. It launches through
explorer.exe now, which is already running as the signed-in user. The exe
manifest is asInvoker, so only that one launch was ever affected - which is
exactly the launch the report describes.

The trap is that a relocation was recorded in exactly one place. A directory
now says what it is: a move writes .reasonix-root.json once the copy is proved
and before the configuration is told, so a marker means this folder is
complete, and it is removed from a location the root leaves. A preflight whose
target already holds this root's own data plans an adopt instead of a refusal -
the pointer alone, nothing copied, verified or reclaimed, and nothing at the
old location deleted. The plan reports what stays behind rather than letting it
go quietly missing.

Structure decides, never resemblance: the marker names the root, or - for a
root that declares what it owns - the folder holds those entries and nothing
else. A root with sole occupancy and no marker cannot be told from a stranger's
folder, so it is refused rather than guessed at. A folder left with only the
marker of a root that moved on is an empty target, not data to adopt.
RecordRelocatedRoots marks the locations a configuration already chose, so a
folder an older build moved data into becomes adoptable after one launch; it
follows the importers' rule and does nothing when the environment redirected
this run's roots.

While in there: PlanMove measured the whole source directory, so a state root
sharing its folder with home counted config.toml and the credentials into a
move that never copies them, and the progress bar could not reach the end it
was measured against.
2026-08-25 01:28:27 -07:00
yhh bd7b4e89aa feat(studio): a node in the run graph is the call you can go read
Clicking a node showed what the kernel knew about it and stopped there. What a
reader wants next is the call itself — its arguments, its output, the reasoning
around it — and the transcript had no way to be pointed at one. Only user
messages carried an anchor in the DOM, because only the rail had ever needed to
find something.

Tool cards now carry the id the graph names them by, nested children included,
which is where a fan-out's nodes actually live. Nothing else reads it, so losing
it would fail silently — the button would switch tabs and do nothing — and that
is what the new test is for.

The rail's two-pass landing becomes one routine both callers share: scroll to
the block first, because the card may sit in one that is not mounted and has no
node to scroll to, then put the element itself under the reader and flash it.
The rail lands on a message, the graph lands on a call; same passes, different
selector.

The request is a counter, not an id, or asking for the same node twice would
land once. It is held rather than spent while the pane is hidden: a hidden pane
has no geometry, every rect reads zero, and the scroll goes nowhere. That also
makes the caller's ordering harmless — the view transition defers what it is
given, so setting the tab and the request in one commit is right, and holding
the request is what makes it right even when it is not.
2026-08-25 00:16:28 -07:00
yhh fe44ff67d2 feat(studio): the run graph is a picture of what waited on what
The panel counted delegates. It could say four were running and never which of
them was blocked on which, because the list it was drawn from had already
thrown that away — the kernel now publishes the structure, so there is finally
something to draw.

A rank is a column: everything in it was free to run at that moment. Width is
concurrency and length is a chain, which is the difference a list renders
identically. Ranks are memoised with a separate in-flight set rather than one
visited set, or a node reached twice through a diamond reads as a root and the
whole picture flattens.

Edges are told apart by line, not by colour. Solid with a filled head means the
upstream answer went into the dependent's context; dashed with an open head
means the pair was ordered and nothing travelled — which is exactly what the
measurement arm produces, and drawing them the same way would say the two runs
were the same run. Reuse is a mark on the node, because an adopted item runs
nothing at all and would otherwise be indistinguishable from work that was paid
for. The state is a word on every card as well as a colour, so none of this
depends on telling hues apart.

The two questions a list cannot answer are the ones the cards lead with: a
blocked node says what it is waiting for by name, and the header says how many
of the fan-out actually ran. A separator that a node id could contain is a bug
waiting for an id with a slash in it, so pairs are keyed by encoding rather than
by joining.
2026-08-25 00:15:49 -07:00
yhh e0e7c0aada feat(agent): the run graph is a fact the kernel publishes, not a shape a frontend infers
The structure was being re-derived in five places and survived in none. A
fleet's dependency DAG lived and died inside one Execute call — the dispatch it
emitted carried a prompt and a profile, so depends_on never left the process at
all. The persisted sidecar recorded a parent link and nothing else. The event
stream implied the rest through id prefixes. By the time any of it reached a
reader it was a flat list of children, which is why a panel could count
delegates and never say which of them was waiting on which.

internal/agentgraph is the vocabulary, and it is a leaf: nodes, and edges that
carry an identity rather than leaving a reader to match words. depends and
context are two edges on purpose. The measurement arm orders its endpoints
while delivering nothing between them, so a single edge type would describe an
ablated run and a real one identically — the same reason an error a caller must
tell apart carries a sentinel rather than a sentence.

The other half of that rule was already being broken on disk.
InheritedContext.Upstream was a bool: it recorded that some dependency's answer
opened a run, and a reader asking which one had nowhere to look. It names them
now, and a sidecar written while it was a flag still decodes to "there were
sources, unnamed" rather than to nothing.

fleet and parallel_tasks publish what preflight already proved, before any of
it runs — an item that never executes emits no tool events to infer a state
from, and an adopted answer emits none at all. That collapses three copies of
the same five-state vocabulary into one and two identical group classifiers
into one, so a group's progress card and its graph node can no longer disagree
about how the fan-out ended.

A node is the call it names. Timing and output stay on the tool stream and a
frontend joins them by id, rather than the kernel publishing the same clock
twice under a second naming scheme. internal/boot asserts that at the frontend
sink through the real Build assembly: a scripted two-item fleet with a
dependency in it, and the edge has to arrive.
2026-08-25 00:01:34 -07:00
esengine 873027d75f feat(studio): the queue is what the kernel holds, not what this window sent
Studio could already say something mid-turn — the composer routes it through
steer, and the row on screen carries the receipt it can be taken back by. What
it could not do is see the queue. The pending rows are minted by the client on
its way out, so they say only what this window sent: reload the page and they
are gone while the entries are still in the manifest, still due at the next tool
boundary. A line the CLI queued, or a second window, was never on screen at all.

So the kernel had ten inbox endpoints and the frontend read three of them.
Editing, reordering, retrying a blocked entry, re-freezing the files one quoted,
holding delivery — all of it existed, with no way in. The panel above the
composer is that way in.

The queue arrives the way the workspace tree and the checkpoint list already do:
an event says it moved, and the content is read back whole. sessioninbox.Store
has had OnChange since it was written and nobody had ever registered one — the
notify path ran into an empty listener slice. Registering it there rather than
at each mutation is what makes this true of every mutation, including the ones
the kernel performs on itself when a turn consumes a steer.

InboxChanged carries nothing. It is tempting to put the snapshot on the wire and
save the round trip, and that would make every client's queue a function of the
frames it happened to receive — a reconnect that missed one leaves a panel
describing a queue that has since changed, and the manifest's checksums and blob
names would be on the wire for no reader. One authority answers instead, and a
frontend that has just reloaded is in exactly the same position as one that has
been connected all along.

adoptInboxLocked exists because there were two places opening a store, and a
listener registered in one of them is a listener half the sessions do not have.

Two smaller things the work needed.

The transport had four verbs and the update endpoint is a PATCH, so it has five.
The contract test that checks the frontend only calls paths this package serves
reads those verb names out of the source: without patch in that alternation the
new call was not unserved, it was unchecked. Confirmed by pointing it at a route
that does not exist and watching the test go red.

Editing opens on the entry's full text, not its preview. The manifest keeps a
120-rune line and nothing more — opening the box on that and saving it back is
how an instruction would quietly lose its last sentence. Measured against a
141-character entry: the row shows 121, the box gets 141.

Verified against a running kernel rather than the fixture, with the entries put
there by a second client so the panel had to have read them from somewhere:
three lines queued over HTTP appear in a window whose transcript never carried
them; an external reorder shows up without a reload; reorder, take-back and
release from inside the panel each land and the panel redraws itself; an edit
saved from the box reaches the kernel. That last one also found a defect this
could not have found any other way — an empty queue is items: null on the wire,
which is what a Go nil slice encodes to, and the panel reading it as an array
would have thrown at the moment the queue ran dry, and only then. Normalised in
the port, where one reader knows it instead of every reader having to.

Deliberately left alone: a line this window sent appears both as a pending row
in the transcript and as an entry in the panel. They are not the same statement.
The row is where it sits in the conversation; the entry is what is still owed,
and it is the one that survives a reload. Folding either into the other would
cost the case the panel exists for.
2026-08-24 18:11:58 -07:00
esengine 3a1b33b519 docs(release): studio 2.6.0 notes
The window is what this version is: the greys came home to the product's own
hue, the activity stream became a rail you can scan instead of paragraphs on
white paper, and the menu bar got an icon that says what this machine is still
doing. Around it, the close button finally keeps the promise its config option
has been making since before this shell existed.

Two things are deliberately not in here. The status icon's own defects — a dead
Win32 pump, a crash on macOS launch — were introduced and fixed inside this
version, so no released build ever had them; the notes say what the icon is, not
what it survived. And fleet's for_each was built, measured against the call it
replaces, and removed in the same span: it never reached a surface, so there is
nothing for a reader to have used.
2026-08-24 09:21:24 -07:00
esengine 871478367d fix(studio): the status icon is built on the thread AppKit requires
Studio did not start on macOS. `open` returned and the process was gone, with
nothing on screen and nothing in the terminal — the bundle path hides the
output. Run the binary directly and it is there:

    *** Terminating app due to uncaught exception
        'NSInternalInconsistencyException', reason:
        'NSWindow should only be instantiated on the main thread!'
        -[SystrayAppDelegate applicationDidFinishLaunching:]
        -[NSStatusBar _statusItemWithLength:]

tray_run_other.go carried a guess about this and flagged it as one: "macOS puts
its status item on the main thread and Wails already owns that one, so handing
the icon a thread of its own — the Windows fix — would be a crash rather than a
dead icon. Neither platform has been run from the machine this was written on."
The first half is right and the conclusion does not follow: nobody was handing
the icon a thread, and the thread it inherited was not the main one either.
Wails calls OnStartup from a goroutine, and the crash frame is m=5.

It is not the ownership conflict it looks like. registerSystray returns
immediately when the loop is external, so systray never takes NSApplication —
nativeStart only allocates a delegate and calls applicationDidFinishLaunching
on it directly, and that is where the status item gets built.

So the defect is narrow, and it is a lone exception in its own library: every
other systray entry point — setIcon, setTitle, every menu mutation — already
hops to the main thread through performSelectorOnMainThread. nativeStart is the
one that does not.

The hop happens here instead, the way this tree already does it. dock_icon
_darwin.m has the same shape for the same reason, and its comment names the same
caller: "the caller is a Wails lifecycle hook that may not be it."

Async, not sync. dispatch_sync would deadlock whenever the main thread has not
reached its runloop yet, which is exactly when a startup hook runs. Nothing
needs the completion: ready() ends by replaying the last state it was given, and
a setter that arrives before the delegate exists is a message to nil.

tray_run_other.go loses darwin from its build tag and the retired guess from its
comment.

Verified with tray at its default, the configuration that crashed: the window
comes up at 1440x900 and the status item is there, titled "Reasonix", its
tooltip reading "Reasonix Studio — 没有在跑的" — which is ready() having run,
built its menu and replayed state, all on the main thread.
2026-08-24 09:12:27 -07:00
esengine 06f25880c9 feat(studio): the activity stream reads as an execution rail
The token system was never the weak half. The activity stream was: the column a
reader spends the whole session in had no material at all, while the rail beside
it has hierarchy, the metrics column has data, and the trajectory tab has a
gantt. In light theme it rendered as paragraphs on white paper — a terminal
block, the largest object on screen, carried border-left: 1px and background:
none.

The timeline was already there in structure and absent in pixels. .call has been
a 20px gutter with a stamp and a connecting line since it was written, but the
line was 1px of --hair, which on a screen is nothing. It is 2px now and takes
the category colour that tokens.css had already assigned and never spent —
neutral reads, amber writes, blue network, teal delegation — so a glance down
the gutter says what a stretch of the turn was doing. Sub-steps inside a
delegate panel keep the narrow gutter: two levels at one width is one level.

Blocks get material. .term sinks half a step by mixing --page into --surface,
one recipe for both themes because page already sits under surface in each. The
approval card lifts out of the flow on an amber wash, and only the one still
waiting: :has(.apv:not([data-sealed])) drops it the moment you answer. Amber
says exactly one thing in this vocabulary — it needs you — and it had been
reaching only as far as the buttons.

Wall-clock becomes a bar beside the number it already prints. It is horizontal,
which was not the first design: drawn down the gutter its length would be
bounded by the card's own height, so a 64s step with two lines of output either
pushes the layout around or gets silently compressed — and a meter that shrinks
when it runs out of room is a meter that lies. Across a header row there is no
such constraint. The mapping is logarithmic because the span is three orders of
magnitude, and the bars right-align against the duration, so a ragged left edge
down a column is the turn's time distribution.

A turn now starts at a visible boundary. Not a new Item type: the user card is
already the anchor the rail jumps to, and UserCard.tsx already records the
decision that turns carry no number, because the rewind entry sits on the card
itself and there is nothing to match up by eye. It gets a rule and some room.

The right column stops being nine equal blocks with nine equal separators, which
reads as an unfinished form. Group boundaries fall on cost, progress,
environment and output, and the first block of each is recognised by the
data-b it already carries — no reordering, no new attribute.

One piece of the plan is deliberately not here. Highlighting the command line
inside a terminal block was going to be .term-l:first-child, and the first line
is not the command: Term splits text on newlines, real shell output routinely
opens with output, and past 300 lines the block is not split at all so there is
no .term-l to select. Guessing a role from a position is the thing this tree
keeps retiring. Doing it properly means telling the renderer which line is the
command, which is a different change.

Second half, found by the sweep the previous commit repaired: 55 light-theme
findings, and they were the design's own rules going unenforced.

--ghost was calibrated against the wrong surface. The comment said "on the
--raised it actually appears on"; it appears on --raised once and on --page 768
times, because rail, side and chrome are all painted in it and it sits a step
darker. The soft tier stopped at 4.03. It is 53% now (4.52 on page), and the
comment says which surface and why. normal and strong already cleared it and are
untouched; dark measures clean on every surface it lands on and is untouched.

--accent is a fill and --accent-ink is the reading value — tokens.css says so,
and 32 rules used the fill as text anyway. In light it clears nothing: 4.07 on
its own wash, 3.91 on page, 3.80 on overlay, while the ink is 4.98 or better
everywhere. --warn is the same hue tier and had 13 more. Only text colours move;
borders, fills and shadows keep the base. In dark the ink is the base, so both
sweeps are light-only.

Two singletons around them. The slash menu's selected row mixes its own tint
rather than using --accent-wash — deliberately, and the comment explains it —
but at 12% the ink on it reached 4.40, so it is 9% and the row is no less
selected. And white on --accent sat at 4.4977, which is not 4.5; #A7690C to
#A6680C is one step of a hex digit, invisible, and lands it at 4.56.

Verified on the real shell against fixture data, both themes, every surface the
sweep can reach: 55 and 45 findings become 0 and 0, with truncation, spill,
offscreen and unnamed-control all empty and no runtime errors.
2026-08-24 09:11:38 -07:00
esengine d1e78ed728 fix(tools): the contrast probe measures the colour the browser painted
getComputedStyle now hands back oklch() and color-mix() verbatim, and parseC
dug the numbers out of whatever string it got. Three components that mean
lightness, chroma and hue were read as if they were r, g and b, so every
judgement compared two colours nobody had painted: 1037 findings on a dark
sweep, all of them "#01004B on #00004B", a pair that appears nowhere in the
token table.

A gate that fires on everything is worse than no gate for the same reason one
that fires on nothing is — both bury the findings that are real. This one had
gone all-red, and the 55 genuine light-theme findings underneath were
indistinguishable from the 807 it invented.

The fix is to stop parsing. Paint one pixel with the computed value and read it
back: whatever the browser resolved is what gets measured, and the probe needs
to know nothing about colour syntax it will have to keep up with. Dark drops
1037 to 45, light 807 to 55 — and those 55 were real, which is what the next
commit is about.

One other correction while in there: the root background fell through to black
when html was transparent, because a fully transparent colour still parsed to
four numbers. It is only adopted now when its alpha says something was painted.
2026-08-24 09:10:38 -07:00
esengine 04203d9629 fix(control): an image the host would reject never enters the history
A vision host rejects an oversized image as an unsupported *format*. Measured
against api.deepseek.com/anthropic on 2026-08-24: 8192x1000 is accepted,
8193x1000 is not, and neither is 1000x8193 — the limit is a single side, while
total pixels (8000x8000 passes) and bytes (a 12 MB PNG passes) are not bounded
at all. The message names webp, png, jpeg and gif, so the reader spends the next
hour confirming the file really is a PNG. It is. It is too wide.

Worse than the wrong question: the rejected user message stays in history and is
resent every turn, so one attachment ends the session. Nothing in the tree
stripped it and retried; ProviderErrBadRequest had no consumer at all.

compressForVision scaled on side length but forwarded the original whenever it
could not: an undecodable mime, a header it could not read, and — the one that
fires here — anything past the 50M-pixel decode limit. A long diagram is exactly
that shape, so it skipped the 1568 budget and went out full size.

The budget is now the gate. internal/visionimage owns the only judgement, and
what leaves it has both sides within MaxDim or does not leave: no vendor number
is written down anywhere, because 1568 already sits below every host's ceiling
and a declared 8192 would be one vendor's answer imposed on the rest. What
cannot be proven to fit is refused with ErrUnfit, an identity a caller can read,
and the reason names the dimensions and what to do about them.

It is a package rather than a control helper because MCP tool results are the
same hole reached from the other side — toolResultImage validated base64 and
size and never looked at dimensions — and internal/plugin cannot import control.
Both callers now ask the same function.

Around it, a turn's attachments stop being three loose values. turnImages says
what the model got, what a delegate could get, and what nothing got, which
retires orchestratedTurn.imagesResolved: a resolved turn is a non-nil pointer.
The headless entry points compose through imageRoutingPrefix like the
orchestrated one does — they bound the attachments but dropped the note, so a
model with an unreadable attachment was told nothing and went off to write its
own OCR script.

Refusing is only half of it; saying so is the other half. The user gets the
reason and the file, and the model is told an attachment exists that it will
never see, so it does not answer as though nothing was attached.

Verified against the real endpoint, one arm each side of the budget:

    12000x5000 (60M px)  refused locally, "past the 50000000-pixel decode
                         limit ... shrink it first"; the model relays it and
                         suggests sips -Z 7000. The turn completes.
    20000x1000 (20M px)  downscaled to 1568x78, sent, answered correctly.
    12000x5000 sent raw  400 unsupported image — what shipped before this.

Two deliberate gaps. The 50M-pixel decode limit is unchanged, so an 8000x8000
image the host would have taken is now refused locally; raising it trades
against decode-time memory and wants its own measurement. And a session already
carrying a rejected image still needs a rewind — this stops the poisoning, it
does not clean up after it.

Test fixtures that were PNG signatures over zero bytes are now decodable PNGs:
an image whose dimensions cannot be read no longer travels, so a header-only
fixture asserted nothing.
2026-08-24 09:01:02 -07:00
yhh 744e62bf38 feat(repolint): the two SPECs stay in step where they claim to say the same thing
Nothing held docs/SPEC.md against its Chinese companion, which is how 3.13 lost
a table row and 3.14 fell three commits behind before anyone noticed — long
enough for one of its sentences to become a false statement about where
dependencies travel.

Parity is declared, never inferred. The companion abridges most sections on
purpose: 3.7's 111-line list is nine lines there, and twelve of seventeen
sections differ structurally by choice. A whole-file rule would fight that and
land twelve findings of recorded debt on day one. lockedSpecSections names the
five that are full translations — 3.11 and 3.13 through 3.16, the delegation
group, which is where changes actually land — and adding a section to it commits
every later change to that section to both files. It is the same reason the
host's sensitive paths are declared rather than read out of how a path is
spelled.

The check reads structure and never wording: the heading sequence across both
files, and each locked section's sequence of block kinds. Size is compared only
where a translation cannot legitimately change it — a table row and a fenced
line survive translation, a wrapped sentence does not — so prose line counts are
never compared, English hard-wrapping at 80 columns against unwrapped Chinese
stays quiet, and a test pins that. Comparing prose would fire on every
legitimate translation choice, which is a judgement this tool cannot read from
structure.

It lands at zero recorded debt: the five locked sections are in parity as of the
previous commit, so the rule's budget is zero and any drift fails rather than
being absorbed.

Verified against the drift it was written for. Dropping the 3.13 table row that
actually went missing reports:

    docs/SPEC.zh-CN.md:282: [spec-parity] section 3.13 table block 2 has 9 lines
    here and 10 in docs/SPEC.md

and exits 1.
2026-08-24 06:09:09 -07:00
yhh 3f0797f500 feat(agent): a fleet failure carries an identity, and a dead branch is not a cancellation
A failed item collapsed every non-cancellation error into one status plus a
sentence, so the caller deciding whether to re-issue the graph with the finished
nodes adopted had to read the message. The identity already existed one layer
down and was being discarded at the fleet boundary: provider.RetryableStatus
knows 429 from 400, and APIError.ErrorCode knows an overflow from a rejection.

Confirmed the identity survives the trip before building on it: a
*provider.APIError returned by Stream reaches RunProfileSpec intact, status and
code readable through errors.As. Classification therefore reads what the error
already carries rather than decorating it.

Four classes, each sourced from a real identity — provider.transient (408, 429,
5xx, or an interrupted stream), provider.rejected (any other 4xx),
context.exhausted (ErrCompactionRequired, or a documented context-overflow
code), and unclassified for everything else. Nothing reads message text. An
error whose producer named nothing is reported unclassified rather than guessed
at, and a test pins that an error whose message reads "rate limit exceeded"
stays unclassified: this is the rule that retired the phrase tables, and a
failure surface is not where it gets an exception.

The identity travels to the branch the failure killed, because a skipped item is
worth re-issuing exactly when its cause was.

Skip policy is unchanged. Retrying inside a run is a separate decision and needs
its own evidence; adopt_ref is already the caller-side retry primitive.

Second half, found while testing the first: a skipped branch made runFleet report
the whole call as cancelled and answer context.Canceled. A rate limit is not an
interruption, and that error is what the turn loop reads to tell an interrupted
call from one that ran and reported. Only a real cancellation answers it now —
including the pre-cancelled context the old skipped check happened to cover — and
the aggregate headline gained the third state it was missing, so a run that
reached its end with failures says how many of its tasks were answered instead of
claiming to be either clean or cancelled.

docs/SPEC.zh-CN.md had drifted three commits behind on 3.13 and 3.14: the
upstream payload, adopt_ref with list_subagents, and the measured for_each
exclusion never crossed, and one sentence had become false — dependencies "never
reach ProfileExecSpec" after they had started travelling on
ContextRequest.Upstream. Both files now describe what the kernel does.
2026-08-24 06:09:02 -07:00
yhh 349c4523d1 fix(studio): the first click on the theme toggle changes what you see
The cycle ran a fixed ["auto", "light", "dark"], and "follow the system"
looks exactly like whichever of the two it currently resolves to. On a
light machine the default auto stepped to light: not one pixel moved, so
the control read as broken. The next step now comes from what the system
is rather than from the array's order, and the first click always
repaints.

One hop stays silent -- returning to auto lands on the scheme auto already
shows, and that is what those three states mean. The button answers the
press itself so the click is acknowledged either way.
2026-08-24 06:01:31 -07:00
yhh c914759047 feat(studio): the window reads as an instrument, not a card deck
Every neutral in the palette sat at hue 255-267 while the accent sits at
75: the greys were 185 degrees from the one colour the product owns, which
is what made them read as somebody else's. They are OKLCH now, at the
accent's hue and a chroma low enough to still be grey, with the lightness
carried over untouched from the hex each one replaces -- so every contrast
ratio the comments there argue for still holds.

The four surface steps were declared but only the centre used them, so the
window was one flat plane with hairlines drawn on it. The frame drops to
the canvas and the middle column becomes a sheet on it. Radii go to
1/2/3/4: an instrument is machined, not moulded, which is what lets rules
and columns take over the dividing. Terminal output, delegate panels and
returns, the approval, and the right column's sections all lose their
boxes for rules.

--warn and --accent were the same hex, so "each colour does one job" was
not true of those two: needing you and costing you were indistinguishable.
warn moves to H45 at 8.14:1 dark and 5.83:1 light, matching accent's 8.35
and 5.77.

The mono stack shipped no face, so the layer carrying every label, tag,
figure and terminal line rendered in Cascadia on Windows and SF Mono on a
Mac -- the problem the comment above --ui says was solved, still open one
line below it. IBM Plex Mono Var, 81KB, OFL like the other two. It has no
geometric shapes and no U+2717, which fall through to the shipped Noto, so
they stay identical across platforms but off the monospace grid; the two
places a check and a cross share a marker column use x instead.

Motion: a weight tier now decides duration and easing together, and
choreography has one mechanism -- sibling-index() rather than fifteen
hand-written nth-child rules. The composer row had grown to eight items
against a chain that stopped at seven, so the eighth entered with no delay
at all. Streamed transcript cards are deliberately not staggered: they
arrive one at a time already, and delaying them would delay the work.

Light is emitted by readings, never painted on surfaces: the live symbol
blooms in its own category colour, the gauge leaves a phosphor edge as it
grows, and a finished turn gets one sweep and one flash. A grain overlay
takes the sterility off surfaces that are otherwise perfectly uniform,
which no real material is.
2026-08-24 06:01:20 -07:00
yhh 2b55a7d6ce fix(studio): the retry line outlived the turn it was reporting on
event.go states the contract: a Retrying indicator is transient, and the
next stream event -- or TurnDone -- clears it. The chat TUI does that. The
window only cleared it on a text or reasoning delta, so a turn whose first
packet was a tool call left "retrying 1/5" and its clock standing for the
rest of the turn, over calls that were running fine.

The clearing moves to a rule about event kinds: the kinds that say nothing
about the connection leave the wait standing, and everything else is the
provider having answered.

Two more the same measurement turned up. RetryScope was on the wire and
ignored, so a body-phase replay was announced with the header-phase
sentence -- 1/5 is maxStreamRecoveries, and headers retry ten times. And a
retry between two steps used to show nothing at all, because the indicator
only rendered inside a wait that the first packet had already ended.

The clock now counts from the stamp the wait carries rather than from the
component's mount, so it reports the stall rather than the turn.
2026-08-24 06:00:52 -07:00
yhh 4e83333b83 feat(telemetry): why a request paid full input rate, from the kernel that knows
The prefix is what prompt caching pays for and one changed byte re-bills all of
it, so the useful question is not how often the cache held but what stopped it.
CompareShape already answers that every turn — system, tools, or the reason a
Rewrite queued — and the verdict rode to the frontend and stopped. This carries
it: cache_miss_reason, one bucket per attributed cause, nothing inferred here.

Rewrite takes an open reason string, so an unknown one buckets as other instead
of letting a caller widen the wire's bucket space.

The complement is the case the kernel cannot see. A prefix that held and still
returned no cached tokens is a gateway that dropped cache_control, an entry that
expired, an endpoint that never cached — downstream, unattributable, and exactly
the shape of the route that billed twelve million prompt tokens at full rate
with nothing anywhere reporting it. It buckets as unexplained, above the
smallest prefix any vendor here will cache so that turns which were never
cacheable stay out of it.

uploadSignals is the silent gate: a counter the sink records but the list omits
is queued and dropped without a word, and locally that is indistinguishable from
a feature that works. A guard now drives a turn through every recording branch
and fails if the wire rejects what came out. It found tool_call_reasoning_recovery
dropped since it shipped — the bucket is a ProtocolRecoveryKind, a closed enum,
never content — and that is now allowed through.
2026-08-24 02:54:49 -07:00
yhh 6eedacc95f revert(agent): fleet for_each removed, three fixtures said no
Built, shipped behind the surface, and measured against the call it replaces —
the same graph with one task handling every subject. Three fixtures, twenty-eight
graded runs against a real provider, every run of both arms solved, and mapping
spent 70%, 85% and 98% more tokens for it (exact permutation p <= 0.03 each).

The last fixture was written to be the case mapping should win: ten modules of
near-identical code, a hundred and sixty lines each, and an answer that is the
sum of ten separate derivations so a slip anywhere is a wrong answer. It did not
win there either, and one run solved it by grepping the constants out rather than
reading anything — which is the reason. A mapped child pays a full sub-agent's
fixed cost per subject, while a single child with a tool belt never has to hold
the subjects at all: it reads them one at a time, and when the facts are
extractable it extracts. "One context cannot hold N subjects" assumes an
ingestion an agent does not have to perform.

So the graph vocabulary goes back to id and depends_on, and SPEC 3.14 records the
exclusion as measured rather than as taste.

Two things survive the removal. The edge dedupe in newFleetPlan stays: a task
named twice in one depends_on was already counted twice before for_each existed,
and the item would have waited for a result nothing publishes. So does
unresolvedDepCounts, which adoption needs — an adopted node enters resolved and
its dependents have to be ready on the first pass.

benchmarks/fleet-fanout goes with it. The history holds both if a case that
genuinely needs per-subject isolation ever turns up.
2026-08-24 02:35:51 -07:00
yhh 07a6ca3028 feat(repolint): a Claude model on the OpenAI dialect is a bill nothing reports
Claude caches only where cache_control marks a breakpoint, and that field is
serialized by internal/provider/anthropic alone. The same model reached through
an openai-kind entry is not an error and not a visible wire difference — it is
full input rate on every prompt token, and the only symptom is a cache read
that never arrives. Measured on a live gateway: 3610 input tokens with no
breakpoint against 7 plus a 3603-token cache read with one.

The judgement reads structure and nothing else — an entry that sets both a
dialect and a model list, resolved through the package-level slice the models
usually live in, because an entry that never spells the name in its own literal
is the ordinary case. Fixtures are exempt: a test builds the broken shape on
purpose to assert what it does.

realyyds_test.go pins the reason a yyds-anthropic entry exists at all, in the
//go:build live form this repo already uses for gateway probes.

The one finding is recorded, not fixed: the vendor's Anthropic route could not
be reached to tell a gateway that lacks one from a preset that picked the wrong
dialect, and asserting either in source would be inventing the fact. The
ratchet is where an open question of that shape belongs, and the rule still
fails every new one.
2026-08-24 02:29:01 -07:00
yhh 57b9d26159 feat(telemetry): a turn's token volume, under the surface that spent it
The counters said how often a turn cached well and never how much it cost.
A flat-rate plan is priced on the tail, so the shape has to survive the wire:
turn_prompt_tokens, turn_output_tokens and turn_cached_tokens are magnitude
buckets taken once per turn, because three 40k requests are one 120k turn and
the plan is paid on the turn. The per-turn scalars move behind turnCounts so
both boundaries reset by whole-struct assignment and the next field added
starts clean.

Surface was hardcoded "cli" at both post sites while stats spelled the same
vocabulary a third way, so internal/surface now owns it and boot, telemetry
and serve read it from there. Or(fallback) makes a caller say what an unset
field could only have meant rather than letting the code guess. One machine's
queue is shared by every surface on it, so the flush groups by surface as well
— without that a window's turns post as the bare server's.

The window and the headless server reported nothing at all: DesktopMetrics and
DesktopTelemetry were settings with no sender. They are separate consents, so
backgroundFlush takes the ping apart from the drain and each is honored alone.
Enabled stays fail-closed, so a development build, CI or DO_NOT_TRACK still
refuses, and a non-interactive server counts only where telemetry was turned
on deliberately.

runServeWithOptions moves to serve_frontend.go, where the serve frontend's
entry point belongs; cli.go was 2595 lines and 1795 over the ceiling, and the
extraction lowers its budget by more than the move costs the file that gains it.
2026-08-24 02:28:22 -07:00
yhh 1d6ca9d18b fix(agent): fleet for_each ships off, because it was measured
Two fixtures, twenty graded runs against a real provider, and mapping never once
solved something the single-task form did not: 20/20 on both arms. What it cost
was 85% more tokens on the cheap fixture (exact permutation p=0.002) and 98% on
the one written to favour it — ten modules each hiding its effective timeout
behind a three-step derivation, where grepping the constants answers nothing
(p=0.029) — with no wall-clock gain either time, at the default concurrency and
at a ceiling raised past the mapped width.

So the mapped fields leave the provider-visible surface. The schema field and the
sentence describing it are dropped together, so the description never advertises
a parameter the schema does not carry, and a call naming for_each is refused
rather than half-honoured.

There is no setting. A capability the host cannot recommend does not get a knob:
turning it on is a change at the wiring point, to be made once the case that is
still untested is measured — subjects whose combined content one child cannot
hold at all, which neither fixture builds.

The code and its tests stay, and benchmarks/fleet-fanout now holds both pairs.
The evidence for switching it back on and the evidence for deleting it is the
same suite either way.
2026-08-24 02:14:19 -07:00
yhh 81015db719 bench(fleet): for_each measured against the call it replaces
A capability has no ablation arm — there is no "the same thing with the payload
removed" for for_each the way the dependency edge has one — so the honest
comparison is what this call had to look like before it existed: one task
handling every subject. benchmarks/fleet-fanout holds both, identical except
that the middle item takes for_each rather than depends_on, graded the same.

Against the real provider, n=6 per arm across two concurrency ceilings, both
arms solved 6/6, and mapping a one-file check over twelve subjects spent 85%
more tokens (exact permutation p=0.002) and 47% more money (p=0.026) for no
wall-clock difference (p=0.70). Raising max_subagent_concurrency past the width
did cut the mapped arm's wall time, and cut the single task's by more.

The mechanism is a sub-agent's fixed cost — prefix, reasoning, closing contract,
once per subject — against per-subject work of reading one short file. It is the
same boundary the dependency edge has: both pay in proportion to what a unit of
work costs relative to that overhead, and both fixtures so far measured the cheap
side. So the tool description now says to map only work that needs a run of its
own, and SPEC 3.14 records what the fixture measured and what it did not.
2026-08-24 01:42:57 -07:00
yhh d484e88ce5 feat(agent): a fleet node's width may be dynamic, its shape may not
for_each names the item whose submitted subjects this item maps over: its prompt
becomes a template run once per subject, and it reports one bounded aggregate
that its dependents open with over the ordinary dependency edge.

The mapped children are never vertices. The node stays one, so the reachability,
write claims, and skip rules preflight proved are the ones that ran — adding N
vertices mid-run would invalidate the precomputed reachability that
skipDependents and the concurrent-claim check both read.

Four things are fixed before anything starts, and that is what keeps for_each
from being the first keyword of a workflow language: the template, the width
ceiling (max_items, defaulted to 8 and hard-capped at 32), the read-only grant —
which removes the write-claim analysis the host could not have done for an
unknown number of writers — and the source. The source supplies only which
subjects, through submit_items rather than prose, because reading a list out of
an answer is the host guessing at a judgement the structure does not carry. The
caller owns the topology, the worker owns the data: the same split the
dependency edge makes.

submit_items reaches only a source's registry, so a capability nothing in a run
can use never enters its prompt. A source that finishes without submitting fails
the mapped node rather than quietly mapping nothing, while an empty submission is
a real answer that completes it. A source may not be adopted, having never run to
submit anything, nor itself mapped, since it answers with an aggregate rather
than a list.

SPEC 3.14 said the graph vocabulary had no dynamic fan-out. It now says no
computed edges, which is the invariant that actually mattered.

Execute's preflight moved into preflight(), which returns a proven fleetCall,
and the ctx-gated tool attach sits beside the other ctx-keyed host framing in the
two sub-session entries; that is what paid for the new lines without widening the
repolint baseline.
2026-08-24 01:25:18 -07:00
yhh 01ee3830b1 feat(agent): a fleet dependency edge carries the answer, not only the order
depends_on ordered its endpoints and delivered nothing, so a research →
implement pair paid for the same research twice. Three changes that compose:

- The edge carries the dependency's answer. It travels as
  ContextRequest.Upstream, never inside TaskSpec.Objective, because the
  objective must stay the pristine text delivery classification judges —
  ClassifierTaskText is captured before any host framing. One 8 KiB budget is
  shared across a node's dependencies, so a wide fan-in cannot flood the
  opening turn and no dependency is starved out of it.

- list_subagents enumerates the children a conversation owns. A reference is
  the only address a child answer has, and a background fleet that a restart
  repaired to interrupted never delivered the aggregate carrying those
  references, leaving its completed children unreachable.

- adopt_ref stands a node down to an already-completed child's answer instead
  of running it. It is proven in preflight through ReadFinalAnswer, so what may
  be adopted cannot drift from what read_subagent_result may read. Nothing
  runs, so the node claims no write_paths and cannot collide with a live
  writer; prompt and every execution field are refused on it rather than
  silently ignored.

Whether an adopted answer is still valid is not readable from structure, so the
host does not decide it: adoption is addressed by reference, and the caller that
listed the reference judges freshness. That is also why a plan fingerprint does
not quietly reuse matching children — reuse the caller cannot see is reuse it
cannot correct.

A child now inherits something, which is exactly what InheritedContext exists to
keep a decision rather than an accident, so the capsule records it and SPEC 3.13
says so.

--ablate upstream leaves the edge ordering its endpoints while delivering
nothing, which is what lets the payload be measured apart from the order.
Against the real provider on benchmarks/upstream-edge/fleet-edge-wide, n=6 per
arm, removing the payload cost +27.6% tokens (exact permutation p=0.011) and
+59% sub-agent tool calls for the same answer, 6/6 solved either way. On a fact
one grep away it buys nothing: the edge pays in proportion to what the
upstream's work cost against how small its result is.

boot.go's delegation registrations moved to delegation_tools.go and Execute's
spec loop to buildItemSpecs; that, plus folding two defaulting branches into
firstNonEmpty, is what paid for the new lines without widening the repolint
baseline.
2026-08-24 01:03:50 -07:00
yhh 2e240a46a9 fix(serve): a submit the turn cannot take is queued, not refused at the reader
The refusal carried "use POST /inbox/items for durable follow-up" as its
detail, and the frontend's wording for that code interpolates the detail — so
what reached the screen was half a Chinese sentence with an API route glued to
it. The kernel was telling the client what to do and the client was showing
that instruction to a person.

Both halves. The refusal keeps its code and drops the route: the English is for
a log now. And the client does what the code asks — it queues the words as a
durable follow-up, which is a row that says it is waiting rather than an error
nobody can act on. That also closes the race behind the report: the turn reads
as finished on screen while the kernel is still landing it, and typing in that
window produced a refusal for timing, not for content.

A queued row now says which wait it is in. Guidance lands at the next tool
boundary; a follow-up goes when the turn does, and the two are not the same
promise.
2026-08-23 20:55:36 -07:00
yhh 707426b454 fix(i18n): the native words follow the machine on Windows too
DetectLanguage reads REASONIX_LANG, then the POSIX locale variables. Windows
sets none of them, so every native surface on a Chinese Windows fell back to
English — the CLI's own words, the folder picker, the status icon's menu —
while the webview beside them read the browser's list and got it right. Two
halves of one app answering the same question differently.

The machine is asked last, after anything set on purpose. It goes through a
variable so the priority between the two is testable on a machine that answers
either way, rather than only on one that happens to be installed in Chinese.

Catalog could not use it: an empty preference means "follow the machine", and
what Catalog returned instead was whatever the process had already installed —
English, for a window that installs nothing. CatalogFor resolves the same way
DetectLanguage does and installs nothing, which is what a shell whose interface
language is a setting of its own actually needs.
2026-08-23 20:55:27 -07:00
yhh eff433b716 fix(desktop): the status icon answers, persists, and says what it depends on
Three defects, one feature. The icon drew and then answered nothing, left
button or right: systray creates its window and pumps GetMessage on whichever
goroutine calls Run, and RunWithExternalLoop — which reads like the embedding
path — starts that pump on a goroutine nobody pinned. A Win32 message queue
belongs to a thread, so the window sat on one and the pump read another. It has
its own locked thread now, on Windows; the other platforms keep the documented
path, because macOS puts its status item on the main thread and Wails already
owns that one.

Clicking it did nothing for the commonest case, a window that was open and
merely buried: show and unminimise are both no-ops on that, so the raise has to
be asked for. And the icon read as a brown blob with a black slit, because the
notch was painted in the window's own dark page colour — an assumption about
the taskbar behind it that a light one disproves. It is cut out now.

The switch for it could not be switched: the field took the value and the
renderer never wrote it, so every save dropped it and the setting turned itself
back on. A setting the renderer drops is a setting that does not exist, and the
round trip is now a test.

The settings pane draws the dependency instead of enforcing it invisibly: the
close-to-tray switch hangs off the icon switch, indented, because without an
icon there is nothing to bring a hidden window back and the second switch is
not a choice. The icon itself is a launch preference — systray quits once per
process, so an immediate off would be a switch that cannot be switched back.
2026-08-23 20:55:18 -07:00
yhh a58b902acf feat(desktop): a status icon for a window that can be closed without leaving
[desktop] close_behavior has been normalized, migrated and editable since
before this shell existed, and read by nobody: quit|background was a promise
the app did not keep. This is the half that was missing. The close button hides
where an icon can bring the window back, and quits where it cannot - no icon,
no backgrounding, because a window that vanished with no way back to it would
be worse than either.

What the icon says is folded in internal/traystate, which owns no OS handle and
draws nothing: panes in, one State out, ranked so that being needed outranks
being busy. Nothing on the wire announces an answer to an approval, so the fold
reads the first thing the run produces afterwards as the resolution - a turn
blocked on one produces nothing until it is answered, which is what makes that
sound rather than a guess. Background jobs are pulled on a timer instead,
because a job announces its life in a sentence and matching those words is the
judgement this codebase keeps retiring.

Quitting from the menu runs the window's own close sequence rather than a
second one. The reason that sequence exists - two processes writing one
transcript - does not care which control asked for it.

Windows is verified: it builds, and the icon's own encoder is the 22-byte ICO
header around a PNG. macOS and Linux compile from here but cannot run: this
machine has no CGO toolchain for either, and the thing to check there is
whether systray's external loop is happy beside Wails' own on the main thread.
2026-08-23 20:05:48 -07:00
yhh 5b07726e8e fix(desktop): the config routes the shell never learned about
/config/problem and /config/repair reached the kernel over HTTP and nowhere
else: the window routes by table, so both answered with index.html, and the
banner that says a config file will not parse could never load in Studio. The
dev proxy had the same hole.

The two guards that read the kernel's own registrations are what caught it,
which is the whole reason they read those rather than the client.
2026-08-23 20:05:31 -07:00
yhh 41c7bbc0df feat(inbox): a queued line can be taken back
Typing while a turn runs queues the line for the next tool boundary, and nothing
could cancel it. The durable item had an id and a delete route, but the delete
refused the one state a queued steer is in, and the frontend had thrown the
receipt away - so the row on screen had no name to cancel by.

The authorization comes from the executor's queue rather than the durable state.
Its lock is the only place that can tell "not read yet" from "already on its way
to the model" without racing the run loop, so the drop there is what permits
removing the durable half: in that order, and not at all if the drop fails. Too
late answers with its own identity instead of a state name, because "removed"
and "already gone" are different sentences to the person waiting.

It rides the verb that already existed, so the CLI's inbox pane, ACP and the bot
inherit it with the controller. Studio grows one control beside the queued badge,
visible without hovering for it.
2026-08-23 19:33:22 -07:00
yhh a0db6a0860 fix(config): a file that will not parse says where, and offers the repair
The settings screen showed built-in defaults while the user's own config sat
unreadable on disk - settings nobody had chosen, presented as theirs - and said
nothing until a save was refused with a parser's sentence about columns. The
runtime already recovers from this by loading the last-known-good snapshot;
loadForEdit does the same now. The save guard is unchanged, because a file
nothing has read is not a file to overwrite.

The refusal carries an identity. A config that will not parse is one condition
wherever it surfaces, so every panel answers config.unparsed with the line, its
text, and the one repair that is certain: a Windows path in a basic string, said
the way TOML accepts. The parser locates the escape it refused and the parser
confirms the result, so nothing here reads the message it wrote and a file it
cannot finish is left alone. The sandbox explains itself by code for the same
reason - the English under a Chinese heading was the kernel talking to a person.

While that path was open: the 24 places in internal/serve answering with a bare
error string now send a code, and refusal-path guards that envelope the way it
already guarded http.Error. A body carrying other fields is a report a panel
renders, and stays out of it.
2026-08-23 19:32:31 -07:00
yhh b1e01eac40 fix(workspacelease): the lease ends with the run that took it
A background job held the workspace writer lease until it was terminal, and for
a dev server that is never: the next session's first write waited behind
something that was never going to end, told only that it would continue
automatically when it was safe. The retention hook fired for every registered
job, so the case it really covered - a process the user left running - is the
one case it must not.

Agent runs already take the lease for themselves, so nothing needs the hook. It
is gone, with RetainUntil and the jobs option that carried it. A background
subagent still holds the lease for its whole run, because that run is bounded;
a process left behind is not.

The wait it does report is legible now. Contention under a second stays silent -
it is either milliseconds or a whole turn, and reporting the first kind left a
permanent line about a wait nobody experienced. Past that the open is a warning,
because the turn is stopped for the length of it, and it is always closed by a
report carrying the measured wait. Studio holds the state on the run strip while
it lasts and rewrites the card in place when it ends; a headless run sees both
ends on stderr, where it used to see nothing at all.
2026-08-23 19:31:05 -07:00
esengine d6c6581904 docs(release): studio 2.5.0 notes
Remote workspaces are what this version is: a machine reached over SSH whose
folders and conversations sit in the sidebar beside the local ones. The rest
is what that took — a Windows host, a fingerprint someone has to read, a pane
addressing a runtime of its own over there — plus the effort ladder becoming
a fact about the endpoint, which is the one entry that changes what an
existing setup does.
2026-08-23 08:45:50 -07:00
esengine 66f24abb38 fix(boot): the golden the jobs schema change left behind
`until_output_matches` and the reworded timeout_seconds description are a
deliberate change to the provider-visible tool surface, so the recorded
baseline moves with them.

Cache impact: the tools hash changes and the prefix hash with it, which
cold-starts the prompt cache once per machine. The system prompt is
untouched, and the schema grows by 91 tokens.
2026-08-23 08:32:24 -07:00
esengine a047ab73d4 feat(jobs): a wait can name what it is waiting for
Polling a long job costs a round per look, and the alternative was waiting
blind — so the model kept choosing to poll. wait now takes a pattern and
returns the moment a job writes something matching it: one round for a job
that runs an hour, and still woken by the line that matters.

The pattern comes from the caller, because only the caller knows what it is
waiting for. A host deciding which words deserve an interruption would be the
same guess this codebase keeps retiring, and RE2 makes an untrusted pattern
linear rather than a way to hang the run.

Two things a naive version gets wrong, both found by the tests failing:
a watch registered after the line was written must still match — otherwise
asking a moment too late waits out the whole timeout for something already
true — and one registered mid-line must be seeded from what is already there,
or a pattern the next write completes is never seen.

What a wait reports is a reading, not a verdict: how long the job has run,
everything it has produced, and how much of that arrived while this call was
waiting. Slow progress and a stop differ there, and which one a task should
look like is the caller's to know — not a threshold anybody has to configure.
2026-08-23 08:18:43 -07:00
esengine 826ddc2cd7 fix: what CI caught, and a job that was working being called stalled
Two CI failures, both mine. Windows lint: the agent method was offered
unconditionally there, since the pipe name is fixed and nothing read an
environment variable to decide — so the caller's nil check could never be
false. It knocks first now, which is both what the check wanted and one less
dial per handshake on a machine with no agent. Windows tests: three of them
built a JSON body by hand around t.TempDir(), and every backslash in a
Windows path is an escape, so the body never parsed.

And the report behind it: a background job that redirects its output into a
file is silent on its own streams, which the watchdog called "may be stalled"
after fifteen minutes of it downloading a hundred megabytes. Silence is the
fact; stalled was the guess. wait now says how long a job has been running
and whether any of it ever reached this side, so the difference between a
task writing elsewhere and a hung one is visible rather than inferred — and
the answer names the longer timeout instead of another short step.
2026-08-23 07:42:01 -07:00
esengine e285269d53 fix: a stream that carried nothing, and three lines that said one thing
Two halves of one report from a user, whose screen showed the same notice
twice and then "model finished without a visible final answer 3 times".

The cause: a gateway can answer 200, hold the stream open long enough to look
healthy, and end it having sent nothing — no content, no reasoning, no tool
call, not even a usage record. That reached the turn as an empty answer, so it
spent three retries on it and named the wrong party. Told apart, it is
StreamInterruptUpstreamError like any other failure the far side reports
inside a 200: retried by the request layer, and ending with what happened. A
stream carrying only a usage record is still the model answering, and keeps
the turn's retry — the case that guard exists for.

The screen: repeats fold into one line and a count. Notices carry a code on
the wire that the session state dropped, and with it the ones this build has
wording for read in the reader's language rather than the kernel's English.
A code it does not know still reads.

Classification moved into stream_failure.go, which is what that file is for.
openai.go got shorter for it and the freed budget is reclaimed.
2026-08-23 07:18:53 -07:00
esengine 4080ee9f84 fix(studio): the remote column has the ceiling the local one always had
A machine worked on for months holds thousands of conversations. The local
column caps its list at thirty for that reason — drawing them all is what put
98k nodes in a sidebar once — and the remote tree, added later, drew every one
of them. A host can be folded now too, which is what makes several of them a
list rather than a scroll with the local projects somewhere above it.
2026-08-23 06:58:23 -07:00
esengine 3167110d09 fix(studio): a remote pane hears its own conversation, and leaves the theme alone
Two things followed the focused pane that should not have.

The event pump still asked the far hub for /events with no runtime prefix, so
it listened to that hub's default runtime while the pane sent its turns to the
one it had opened. Everything worked and nothing came back.

Appearance was read and written through whichever pane had focus. A remote
kernel keeps its own copy, so switching to a pane on another machine repainted
this window with that machine's theme. It belongs to the window: the local
pane is what these three now read and write.
2026-08-23 06:53:57 -07:00
esengine 3ee62171ae fix(remote): a workspace keeps the spelling its own machine uses
Windows answers SFTP with /C:/Users/... and its kernel takes C:\Users\... The
first spelling travelled all the way to the far hub, which tried to open
C:\C:\Users\... and refused — reaching the window as a bare 502, because that
one path had no code on it.

The platform converts, since only it knows both. The pane's name is cut the
same way: either separator can appear in a path this machine does not hold,
and its own filepath rules are not the authority on one.
2026-08-23 06:48:54 -07:00
esengine f4b469399f fix(studio): the development bundle is one macOS can verify
It never was. The launcher linked the frontend in from the source tree, and a
symlink whose destination is outside the bundle fails codesign --strict — so
the app had a signature that verified nothing, and an app macOS cannot verify
gets no stable identity to hang a Local Network grant on. That is the whole
distance between "ping reaches the host" and the window saying no route to it.

Both fixes are the released bundle's own: copy dist rather than link the tree
(scripts/studio-build.sh has always copied), and sign --deep, because
everything under Contents/MacOS counts as code and each file needs its own
signature. The launcher now verifies afterwards and says so if it fails,
rather than leaving that to be discovered as a networking bug.
2026-08-23 06:24:26 -07:00
esengine ce8e980a56 docs(remote): the notes say what the code now does
Three places still told the reader that a remote must be Linux or macOS, the
window's refusal among them. What it should say is narrower and more useful:
the link is fine, that machine's system is what a kernel cannot be installed
onto — and name the three that can.
2026-08-23 06:15:09 -07:00
esengine 161963e1d7 feat(remote): a Windows machine can host a kernel
Everything below the SSH link assumed a POSIX shell. It now goes through one
interface — which shell, which spelling of a path, how a process is started
there and found again — so the difference is in one place instead of a branch
at every call site.

Two things about Windows had to be discovered against a real machine rather
than reasoned about:

Detachment. OpenSSH puts a session's children in a job object and ends them
with the session, so a serve started by Start-Process was gone before its
port file appeared. What the WMI service creates has no such parent, and that
is the setsid of this platform.

Quoting. Commands travel as -EncodedCommand — base64 of UTF-16LE, which holds
no character cmd treats as special. That removes the outer quoting problem
rather than solving it: cmd's rules are where this would have become a
security bug, and a path with an apostrophe in it now proves the point in a
test. Inside the script PowerShell's single quotes follow the POSIX rule.

Also: the CLI release ships Windows as a zip holding reasonix.exe, and the
downloader knew only tar.gz holding reasonix — so a Windows remote had no
install path at all.

Verified end to end against a real Windows host: bootstrap, launch, survive
the session, HTTP through the forward to /status and /tree, then stop.
2026-08-23 06:13:32 -07:00
esengine 7f950a6d24 fix(studio): the app declares that it reaches machines on this network
macOS grants Local Network access only to an app whose Info.plist says what
it wants it for. Neither bundle said anything, so the request was never put
to the user — no prompt, no entry in the privacy list, and every LAN host
answering "no route to host" while ping and the CLI reached it fine.

The release bundle had this too: any host on the user's own network was
unreachable from the window and reachable from everything else.

The launcher also signs with a development certificate where one exists. An
ad-hoc signature's hash changes with every build, so the grant would have to
be given again after each one, which is indistinguishable from it not working.
2026-08-23 05:36:09 -07:00
esengine b70fadc0f0 fix(studio): the window does not offer itself as the remote CLI
LocalBinary was copied from the CLI, where the running executable is reasonix
and uploading it is the fast path. Here it is the window: the far side would
receive a Wails app, probe it for a serve command, and reject it — after
paying for the transfer. Left unset, the install falls to npm and then to a
verified release download, which is what it should have done from the start.
2026-08-23 05:29:43 -07:00
esengine 4f7c46c823 fix(remote): a machine that cannot host a kernel says so
Connecting to a Windows host failed as a bare 502 — "the request did not
reach the kernel" — when the SSH link was fine and the far end simply has no
uname. The reason existed the whole way up and was dropped at the last hop:
an error with no code reaches the window as a status and nothing else.

Unsupported remotes now carry a sentinel, so the window can say which half
failed rather than implying the link did. The wording carries no detail on
purpose: what such a machine answers is its own shell's complaint in its own
code page, and putting that on screen explains nothing.

Everything else keeps its text under a coded fallback, which is what makes a
failure legible at all instead of arriving as a number.
2026-08-23 05:20:33 -07:00
esengine f310b3480f feat(remote): the Windows agent is reachable, and its logic is shared
Everything above the dial — what the agent offers, which keys IdentitiesOnly
filters out — was inside the !windows build tag, so the Windows half could
not have reused it even when someone wrote it. It moves to a file with no
tag; each platform now supplies only how to reach the agent.

Windows reaches it through the pipe its own OpenSSH service fixes the name
of, with SSH_AUTH_SOCK winning where a port of the Unix agent set one. A
named pipe opens as a file, which is the same call the agent's own clients
make, so this needs nothing new to depend on.

The tests are the first for this code that run on every platform: an agent
key authenticating, IdentitiesOnly offering one key out of two, and an absent
agent not consuming publickey — the last mattering most where the pipe name
is fixed and the method is therefore always registered.
2026-08-23 05:05:13 -07:00
esengine 58cfc1934b feat(studio): a connected machine shows what is on it
Until now a remote host offered exactly one workspace — whichever the book
named — which made it a shortcut rather than a machine you can work on. A
connected one now answers for itself: its folders and the conversations in
them, read through a pane already open on it, so no second connection and no
second serve process.

That is also why a pane had to address its own runtime over there first. With
one, resuming a remote conversation is the same act as resuming a local one,
and the sidebar can tell which of them this window is already driving.

Unconnected hosts keep the single row, which is also the connect button:
before a kernel is running there is nothing on that machine to ask.
2026-08-23 04:57:02 -07:00
esengine d5538fb8ae fix(serve): each remote pane drives a runtime of its own over there
The proxy strips the pane's prefix, so what reached the far hub named no
runtime and landed on its default. Two panes on one workspace share a link
and an address by design — and were sharing the conversation as well: one
transcript, two writers, both streams interleaved.

A pane now opens its own runtime on the far side and addresses it, which is
also what makes resuming a remote session possible at all. It is retired when
the pane closes, before the forward that reaches it goes away, or the far
machine collects runtimes until its own ceiling refuses the next connect.
2026-08-23 04:49:05 -07:00
esengine 7b5e7a6808 fix(remote): a changed host key arrives as one, not as a network error
The kernel builds a structured mismatch — which record it contradicts, down
to the line — and the path to the window flattened it into a sentence. Now
the link layer names what it knows: a changed key, a declined fingerprint and
a refused credential each carry their own code, and the mismatch carries the
known_hosts record so the reader can go look. There is still no way from
there to connecting anyway; a warning you can click past is not one.

The parity guard did not catch the missing wording, because it matched bare
identifiers only and a host outside the package calls the constructor
qualified. It watches both now — verified by removing a wording and seeing it
fail, which is the only way to know a guard is looking at anything.
2026-08-23 04:23:08 -07:00
esengine 9f19913457 feat(studio): a first connect can stop and ask
Until now the link layer had nobody to ask, so it took the strict path: a
machine not already in known_hosts was refused, and a passphrase-locked key
could not be used at all. Both are questions, and the window is the only
thing that can put one to a person — so they travel out on the shell's bus
and the answer comes back through a binding, since the dial is blocked on it.

The fingerprint dialog is deliberately the harder one to get past. Its
fingerprint wraps rather than truncates, because one with its middle elided
cannot be compared to anything, and the focus starts on the way out: Enter is
a reflex, and accepting a key nobody read is what this exists to prevent.
2026-08-23 04:18:09 -07:00
esengine d7ff93cd73 feat(studio): the host book is editable where the rest of the settings are
A machine was only addable from the CLI, which made the sidebar section
something the window could show but never fill. The book is user-global, so
an entry belongs to the person and not to whichever folder happened to be
open when it was added.

The listing returns the stored row in full rather than the fields a row
displays: saving replaces an entry, so a page editing one field while holding
a partial copy would blank the passphrase variable somebody set from the CLI.
Forwards have no control here and are carried through for the same reason.

Importing beats typing — on a machine that already uses ssh the addresses are
written down next door, so ~/.ssh/config aliases the book does not have yet
are offered as one-click rows.
2026-08-23 04:06:33 -07:00
esengine 7b6ae10e4d feat(studio): a machine's link is something the sidebar shows
Hosts are a section, not a prefix: workspaces on one machine share a
connection, so the state belongs on the group — when a link drops, the whole
section dims together, which is what those rows actually are to each other.

Local workspaces stay unmarked, so carrying a chip is itself the signal. The
same chip appears on the pane tab and in the crumb, because a command lands
on whichever machine the focused tab points at.

A cold connect installs a binary on the far side. The kernel already reports
each step, so the wait is a list of them rather than a spinner that cannot
tell downloading from stuck.
2026-08-23 03:50:03 -07:00
esengine f954ce95b1 feat(serve): a host's link state is something the window can ask for
Two function fields become one interface. The split it draws is the one that
matters: the book of hosts is configuration and stays in serve, while what a
link is doing belongs to whoever holds it — so the hub joins them without
learning what SSH is.

The pool records state whether or not a caller subscribed. A window that
opens a second pane an hour later reads the same row as the one that dialled,
and the connect step a first attach is on has somewhere to be read from.
2026-08-23 03:34:35 -07:00
esengine a33f72f806 feat(serve): opening a pane on another machine is one request
The hub gets the endpoint from a hook the host wires, so the two halves meet
above it: a window supplies a pool that dials, and a listening server leaves
it nil. That default is the point — a kernel that dials onward because a
request asked it to is someone else's way into the network behind it.

A hold the attach took and the hub refused would be a connection nothing can
ever close, so the endpoint gives it back on every path that does not publish
a pane.
2026-08-23 03:25:30 -07:00
esengine a3e7d07484 feat(studio): the window can drive a pane whose kernel is on another machine
Three sweeps reached into a local assembly and would have found none. The
stream needed more than a guard: the asset server holds a response until its
handler returns, so a page inside the window cannot read SSE at all — the
shell reads the far kernel's stream and re-emits it onto the same bus a local
pane's frames ride.

Resuming rides what the stream already carries: the remote numbers its
resumable frames and honours Last-Event-ID, so a link that drops mid-turn
costs only what its replay log has since dropped.
2026-08-23 03:19:02 -07:00
esengine 20d283fbe1 feat(remote): one connection per host, shared by every workspace on it
Dialling a configured host lived in the CLI, where a second frontend could
only copy it. It moves to attach, which adds what a window needs and a
terminal did not: a pool where two workspaces on one machine are one login,
and a forward per workspace that the last pane out takes down. The remote
serve is left running — it outlives the link by design.

Exercised against a real SSH server with SFTP and forwarding, so what the
tests cover is the link and not a stand-in for it.
2026-08-23 03:14:10 -07:00
esengine 53d6c19125 feat(serve): a pane the hub drives can live on another machine
The hub takes an endpoint, not a connection: what makes a pane remote is
where its HTTP goes, so SSH stays above this layer and a proxied pane is
testable over httptest. The frontend addresses /rt/{id}/ either way.

Host decisions reach local panes only — a remote one has no assembly here
to apply them to. The stream is what the proxy has to get right, and the
test withholds the second frame until the first has arrived.
2026-08-23 03:02:09 -07:00
esengine f7dec5d759 refactor: remove the plan-mode bash trust bridge, which nothing invoked
CheckPlanModeReadOnlyTrust had no caller outside its own tests. Everything
behind it was still there: the gate interface and its request type, two setters
and the coordinator that propagated them, the approver on Controller, the
session-trust map in approvalManager, the slot that map occupied in the
authorizations a controller rebuild carries, the boot callback that persisted a
prefix, and two approval-card branches in the TUI. None of it could run.

The bridge was superseded rather than abandoned, and the code says so in three
places: agent's interface ("ordinary Plan execution no longer invokes it; bash
calls use the normal permission gate"), Options ("classifies bash through
Permissions instead"), and config ("Main Plan bash calls now use the ordinary
Permissions classifier and Sandbox"). What was left was the wiring around a
decision already made.

The config field stays. Its comment claims exactly one job — old config and
session round trips — and that job is real: reading a reasonix.toml that still
carries plan_mode_read_only_commands must not fail, and rendering it back must
not lose it. Both are still tested. What is gone is the runtime that field no
longer feeds.

Tests that borrowed the removed tool constant as a sample keep their subject
with an ordinary tool name; the two plan-mode tests keep theirs, which was
never the bridge but what replaced it — bash reaches ordinary permission as the
declared writer it is, and a writer stays behind permissions. Tests of the
bridge itself go with it.

Touches internal/control/approval.go, a declared sensitive path. The change
there is confined to the plan-mode trust map and its slot in
SessionAuthorizations: grantSession, the granted map, requestApproval and
RequiresFreshHumanApprovalTool are untouched, and the rebuild test still proves
an ordinary session grant survives a controller swap. No path gains an
approval, loses a prompt, or widens what may run without one.

Removal was driven by the compiler and then by `unused`, one orphan at a time,
until both were clean. Net -592 lines.
2026-08-23 02:28:21 -07:00
esengine 92c1dd48cb refactor(responses): a per-vendor compaction budget that was never wired
vendorCapabilities declared compactionOutputTokens and two vendors set it, and
nothing read it. Its comment describes a real intent — "the separate budget for
native/summary compaction calls … without inheriting a large default" — and
that intent is already met, one level up and one grain coarser: the fold's
provider call sets MaxTokens from agent's summaryOutputMaxTokens rather than
from the client's answer budget.

The numbers say the same. DeepSeek's entry is DefaultOrdinaryOutputTokens, 16K
— exactly what summaryOutputMaxTokens already applies, so wiring it would have
changed nothing there. The other is 8192, and has never taken effect.

Making it per-vendor is a feature, not this: a compaction call would have to
say so on the request, which provider.Request has no way to carry today. The
coarse limit stays where it works. If the per-vendor grain is wanted later, it
starts from that missing signal rather than from a field nobody reads.
2026-08-23 01:49:11 -07:00
esengine 1bf3db8db6 refactor: two fields that were only ever written
A field that is written and never read is a claim about state that nothing
backs. `unused` does not catch them — a constructor assignment counts as a use
— so a sweep over every unexported field in internal/ turned up the candidates
and each was read before deciding.

transcriptDirty (cli): eleven writes, no reader. Its comments still described
what it did — "forces a viewport re-feed after that in-place rewrite", and one
naming #6978 for what it must never force. #6978 replaced the whole-history
rebuild with invalidateWrapFrom, and setTranscriptBlock calls that on every
in-place rewrite, which is what re-feeds a same-length change. The reader went
then; the flag, the eleven writes and three comments crediting it did not.

recoveryGeneration (agent): a second copy of a generation that already travels.
Its comment says it is captured "so ObserveResult can ignore stale results
after a mode switch" — and ObserveResult does exactly that, from the local
recoveryGen the call site passes it. The copy on toolOutcome went nowhere.

Neither removal can change behaviour: nothing read either one. What changes is
that the comments no longer credit a mechanism to a field that is not part of
it.
2026-08-23 01:44:41 -07:00
esengine 3e1b5134c7 refactor(openai): a flag named after a vendor, renamed to what it decides
c.mimo reads as vendor identity and is not: its one use rewrites tool schemas
out of the pre-2020-12 tuple keywords, which is a fact about the dialect the
endpoint speaks. NormalizeLegacyTupleItemsForDraft202012 says as much — a
provider "must opt in only after the target endpoint's schema dialect is
known" — so the field carries that, not a name.

This came out of looking at whether the client's six vendor booleans should be
one enum, and they should not. They are not one dimension. deepseek, minimax,
zhipu and longcat are thinking protocols and are mutually exclusive, decided by
one switch. This one is a wire-compatibility need, and kimiK3 is a contract
shape; both are resolved independently, and both can be true alongside a
thinking protocol — a base URL at xiaomimimo.com with protocol="deepseek" sets
deepseek and this flag at once, and that is correct: the endpoint wants
DeepSeek's thinking shape and needs its schemas rewritten. An enum would make
that combination unrepresentable and force a product decision — which vendor
wins when the declared protocol and the host disagree — under the cover of a
refactor.

So the scalar count stays where it is. What was actually wrong here was one
field claiming to be an identity.
2026-08-22 23:44:51 -07:00
esengine ba8442c5b8 refactor(jobs): a job's artifact is one thing, with one rule for its error
Job carried five artifact fields and wrote to them from four places, and the
rule for recording a failure was spelled three times in two different ways.
Close kept the first error — "closeErr != nil && j.artifactErr == \"\"" — while
both writes overwrote whatever was there. Once a disk fills every write fails,
so the message that survived was the last one, which says nothing the first did
not. They are one type now, and noteErr is that rule in one place, the way
Close already had it.

The timestamps go the same way: started, activity and finished are the job's
timeline, and activity is the one stall detection reads — a job can run for an
hour with its output stopped in the first minute, which is why it is not
derivable from the other two.

tombstone goes entirely. It was set true in one place, when a finished job is
rebuilt from its persisted metadata, and read nowhere: the state it meant is
already carried by a closed done channel and a terminal status, and Manager
tracks per-session loading separately. A field only ever written is a claim
about who depends on it that nothing backs.

Job goes 18 scalars to 11.
2026-08-22 23:27:11 -07:00
esengine f32088baae test(control): a goal field that never crosses a rotation is caught
goalMachine's state reaches a rotated session through capture/restore, and
both copy field by field. Adding a field to the machine and forgetting it in
either one compiles: the goal simply arrives without it, and nothing reports
the loss. Twenty-one fields are copied by hand in two directions, which is a
lot of chances to miss one.

Two guards read the declarations and both function bodies. What is left out is
named with its reason — the two locks, the epoch restore bumps rather than
carries, and the budget and state path that belong to the controller that built
the machine rather than to the goal being moved — so leaving something out is a
sentence someone writes, not a line someone forgets.

Checked both ways a field goes missing. Adding one to goalMachine without
carrying it: named by the first guard. Declaring it in the snapshot and
dropping its line from restore: named by the second. Removing one from the
snapshot struct needs no guard — that one the compiler already stops.

This is what the 21-scalar ceiling on goalMachine is really asking about here.
Grouping the fields would satisfy it, but the fields are read in 242 places and
the persisted goalState cannot follow them, so the copies would stay hand-
written and the loss they invite would stay possible.
2026-08-22 23:16:33 -07:00
esengine 37429ed837 refactor(control): three settings move to the subsystem that reads them
Each of these sat on Controller and was read in one or two places, all inside
one subsystem: the MCP default call timeout only ever fills in a spec the MCP
manager builds, the implicit-invocation switch is only asked about skills, and
the slash sequence only numbers a slash-invoked skill's synthetic call. They
belong where they are consulted, not on the object that happens to hold both
ends.

skillSet's comment claimed every field is set once at construction, which
slashSeq would have quietly falsified — it now says which field is the counter
and that it synchronises itself.

Controller goes 20 scalars to 17. The remainder is values (the workspace root,
the label, the model ref) rather than flags, and the ceiling is aimed at flags
whose combinations describe states nothing reaches: workspaceRoot alone has 64
readers, and wrapping it to reach a number would cost every one of them a level
of indirection for no state it makes illegal. What Controller actually needs is
the responsibility split the memory and checkpoint managers started, not more
field moves.

The ratchet caught this change trying to bring two-line field comments in and
was right to; they are one line each now.
2026-08-22 23:04:26 -07:00
esengine 426bdb4d40 refactor(control): turn admission is one state, not five loose bools
Controller carried running, finishing, canceling, rotating and closed side by
side. Their own comments say they constrain each other — "running and rotating
are mutually exclusive gates", closed "seals turn admission" over all of them —
which is exactly the shape the scalar ceiling exists to catch: five independent
flags describe 32 combinations, and a controller only ever reaches a handful.

They become turnGate, and the two questions the call sites kept re-deriving
become its methods. `running || finishing` appeared three times and is what a
bare running check gets wrong — the window where TurnDone is still in flight —
so it is active(). The four-way "anything at all in the way" appeared twice, in
two files that had each spelled it out, and is busy().

Nothing moves out from under c.mu: the group is a value field on the same
struct, so every critical section is the one it was. Checked with -race.

Controller goes 25 scalars to 20. The rest do not group this way and should not
be forced to: what is left is mostly construction-time configuration that
belongs to a subsystem rather than to the controller — the MCP call timeout to
the MCP manager, the implicit-skill switch to the skill set, the goal token
budget to the goal machine. Putting them in one bag to reach the ceiling would
read as a group while being a bag.
2026-08-22 22:56:29 -07:00
esengine 432353ef87 refactor(agent): the loose flags on Agent group by how long they live
Agent carried 15 scalar fields against a ceiling of 12. The ceiling exists
because independent flags multiply into states no type records as legal, and
the fix it blocks is adding one more bool — so two groups come out by lifetime,
which is what the rule asks for.

steerInbox is the queue and the two flags around it. They already shared a
mutex and a lifetime — opened on the way into Run, closed on the way out — and
now share a type, so the transitions are its methods instead of seven call
sites reaching for the lock. take() hands the entry back rather than loading
its body under the lock, which is the one behaviour change and the right way
round: no inbox lock is held across a read.

agentRole is what an agent is when it is built and stays: a planner that may
not execute, an executor told to reach for its tools, a caller that needs
visible final text. That fixed lifetime is what separates them from planMode
and the language settings they were sitting next to, which move between turns.
deliveryProfile looks like one of them and is not — beginRunTurn recomputes it
per turn — so it stays where it is.

Agent is at 12, and the reclaim takes the room with it: baseline drops
struct-state for agent.go entirely, along with the file-size and function-size
the two extractions freed. Nothing was raised. The ratchet also caught the new
file trying to carry two essay findings in, which is what it is for.
2026-08-22 22:44:25 -07:00
esengine 9f2a5f1510 fix(studio): a bubble stays in the window when the interface is scaled
Reported: at a larger appearance the context-breakdown bubble runs off the
right edge and is clipped, and the smaller the scale the further left it sits.

Both directions are the same mistake. The interface is scaled with CSS zoom,
and the placement mixed the two coordinate systems that creates. Measured in
Chromium at zoom 1.5: a 232px box reports 348 wide, an element at left:400
reports 600, and writing left:300 puts it at 450. So a client rect and
innerWidth are both read on screen — the clamp against them was right — while
the left written back is multiplied by the zoom again on its way in. At 1.5 the
clamped position landed 50% further right, at 0.8 it landed short.

    scale    right edge, before → after   (viewport 1000)
      0.8            447 → 512
      1.0            640 → 640
      1.3            991 → 832
      1.5           1266 → 960

Dividing once at the end is the correction the stylesheet already makes for the
same reason, twice: calc(100vh / var(--zoom, 1)), because vh keeps measuring
the unscaled viewport.

The tab context menu placed itself the same way and had the same bug, so both
now go through one placer. Its arithmetic is a pure function so the invariant —
what renders has to be inside the viewport at any scale — is checked without a
browser; the browser half is the measurement in the header, taken with a probe
rather than assumed. Removing the division turns three of the four red.
2026-08-22 22:28:00 -07:00
esengine c3efda0f96 fix(history): the search index strips the same blocks the transcript does
stripComposePrefixes kept its own copy of the tag list and named five of the
eleven the host injects. So <workspace>, the two language blocks,
capability-route and interrupted-turn-recovery went into the BM25 index as if
the user had typed them: a query for a path matched every turn of every
session, and the search preview rendered the block instead of the message.

The list now comes from the package that injects it. history already imports
agent, so this is the copy going away rather than a new dependency, and the
scan it shares advances through the blocks instead of re-running an ^-anchored
ReplaceAll per block.

TokenizerVersion goes to 2, which is how an extraction change reaches indexes
that already exist — and that bump surfaced a bug of its own. Rebuild fills the
projection through a Catalog built directly on the migrated db, bypassing Open,
so the row kept the schema default of 1; opening the result then saw a stale
tokenizer and wiped what the rebuild had just written. It stamps the version it
built with now. The constant had never moved, so nothing had ever asked.

Verified by removing the stamp: the rebuilt projection comes back empty.
2026-08-22 22:17:06 -07:00
esengine e7dd1e10e7 perf(agent): the autosave stops re-scanning every turn it has already read
Every save walks the whole transcript to derive the sidecar's preview line and
user-turn count, and each user turn goes through StripTransientUserBlocks. Two
patterns there were quadratic in the turn's own length.

The leading-block pattern is ^-anchored, so a ReplaceAll can only ever take the
first block — and the loop around it re-scanned the whole string once per
block. Advancing past each match reads the turn once and allocates nothing.

The trailing execution-policy cut was `(?s)...*?...\s*$`: a lazy body under a
tail anchor, which backtracks over the string for every start position it
tries. It becomes a byte-exact suffix cut, which is what the delivery-runtime
marker beside it already does — and that one's comment gives the other reason
to prefer it: a lazy pattern anchored at $ swallows user prose between a
literal mention of the tag and the real block at the end.

Measured on one more turn appended to an already-saved session:

    messages    before     after
         100    22.8ms    19.0ms
       1,000    70.0ms    35.4ms     -49%
       5,000   196.1ms   119.3ms     -39%
      20,000   652.3ms   432.6ms     -34%

The benchmark stays. What remains is a different problem and not this one:
loadSessionUnlocked is 43% of the rest, because the write check replays the
event log to see whether another writer moved the file — a crash-consistency
mechanism, not an accident.
2026-08-22 22:08:21 -07:00
esengine 608b76db52 fix(effort): the levels an endpoint takes belong to the endpoint, not the model
Reported: a third-party endpoint serving deepseek-v4-flash answered every turn
with "field ReasoningEffort invalid, should be one of: low, medium, high,
xhigh, none". That vocabulary is not DeepSeek's — it has no "medium" and spells
the switch "disabled" — so the endpoint was a relay speaking the OpenAI one,
and the composer had offered "max" because the ladder is keyed by model name.
Pressing continue re-sent the same setting, so the turn could not recover.

The model table already states the rule for the other field it carries:
ContextWindow "is a fact about the model and not about who serves it — a relay
passing it through has the same ceiling". Levels are the opposite kind of fact
and were being inherited the same way. Each measured ladder now names the
VendorHost it was measured against, and only that host is given it. Protocol
and window keep travelling with the model, which is what the table was added
for; the switch between them is host-only, matching isMimoEntry, so a lookalike
hostname cannot claim a vendor's extensions.

Elsewhere the ladder narrows rather than disappearing — a relay with no control
at all is what the model table was added to fix. What is dropped is the levels
outside the API's documented vocabulary ("max", "xhigh"), and they degrade onto
the deepest standard level instead of being refused, the way "minimal" already
degrades. A relay that does take them says so with supported_efforts, which
outranks everything the host would otherwise assume.

The protocol fallback needed the same split: an entry declaring the DeepSeek
protocol without a table entry was offered max wherever it pointed. Narrowing
it unconditionally is what the first attempt did, and TestNormalizeEffortDeepSeek
caught it — that fixture is the vendor host with an unlisted model, which must
keep max.

Three existing expectations move, all of them the behaviour being fixed: a
proxy no longer lists max, a gpt-5.6 relay no longer lists xhigh/max, and the
gpt-5.6 test that asserted a relay takes them "as itself" now expects the
degrade its own comment describes ("an alias degrades rather than sending a
level the model will reject").
2026-08-22 21:34:47 -07:00
esengine 9fd6f3f654 refactor(evidence): a write is attributed by the tool's contract, not its name
Receipt.Write decides what the delivery gates, the baseline, the path
invalidation and the cross-turn diff evidence can see, and it was set from a
list of seven tool names kept in evidence. A writer added without an entry
there keeps working while its writes stop being attributable to a file, and
nothing fails.

ReadOnly could not answer instead: it is false for bash too, which mutates
without naming what it touched. Previewer could not either — it is shaped
around one path and a move has two, which is why move_file never implemented
it. So the question gets its own contract, PathWriter, and the seven tools
answer it themselves.

ReceiptFromToolCall now takes ToolFacts rather than a bare readOnly bool. The
paths that only have a name — fold coverage, the fold index, the cross-turn
PathsProvenInSession — resolve it through the live registry the way
toolIsReadOnly already did for read-only, and that helper is gone since
toolFactsFor answers the wider question. An unresolvable name reads as a read,
so a replay of a tool this build no longer has invents no writes.

Verified the swap before making it: across every built-in, "not read-only and
its arguments carry paths" agrees with the old name table on all thirteen
legal calls, disagreeing only on malformed ones that fail anyway. That is the
evidence the contract covers the same set, not an argument for that rule —
a future tool that runs a path without writing it would break the rule and
still answer PathWriter correctly.

Three guards in builtin: the seven declare it, bash and kill_shell do not, and
nothing read-only does. Checked by flipping write_file's declaration — the
guard names it, and the cross-turn sign-off e2e goes red with it, which is the
behaviour the contract actually feeds.

Also here, from the same sweep: IsDiagnosticSuccess had a command table whose
every branch returned the same value as the line after it, so it decided
nothing; and its doc claimed it kept a success from clearing the failure event,
which the caller does unconditionally two lines up.

Test doubles standing in for writers now say so instead of being recognised by
the name they answer to, and the fold-coverage test reads the shipped registry
rather than its own copy of the answers.
2026-08-22 21:08:06 -07:00
esengine c218307275 perf(tool): the schema budget gets room, and a guard on what is actually billed
Two things were wrong with the one number watching the largest item in every
first request.

It was at 5700 of a 5700 budget — no room at all, so the next field anyone
adds fails CI with nothing to say about whether that field was worth it. And
it counts the whole built-in set, while a session is billed for the schemas
the provider is shown: 13 of 23, because everything else is reached through
use_capability. Promoting one tool onto that surface costs every session and
moves the guarded number by nothing.

So boot guards the visible surface on its own, at the goal-armed variant
because it is the larger of the two. It fails if the allow-list stops
restricting anything, which is the way this test could otherwise pass while
measuring the wrong set. Checked by lowering it until it went red.

The room comes from the three largest descriptions, each of which ended by
restating its own schema field by field. complete_step's restatement had
already drifted from what it described — it documented `step` while the schema
prefers `step_id` and keeps `step` as the fallback — which is what a second
copy of a contract does. todo_write keeps the two things its schema does not
carry: the two-level shape and the verbatim-step_id rule.

    built-in set   5700 -> 5443    budget 5700 -> 5600
    visible        3841 -> 3584    new budget 3700
    goal absent    3305 -> 3141

Cache impact: 256 tokens leave the provider-visible prefix, so every existing
session cold-starts once and is warm again after. SystemHash is unchanged in
the golden, which is the evidence the base prompt was not touched; ToolsHash
and ToolSchemaTokens (4531 -> 4275) are the tools alone.

The fold effect test came along because it measured this indirectly: it
compared the last request against the run's peak, so a cheaper prefix delayed
the second fold and left more turns after it than before. It now checks each
fold where it happened, which is the invariant it was written for. Verified by
disabling the strip: the counts go linear and it names the fold that kept them.
2026-08-22 20:30:51 -07:00
esengine 12251e76b4 refactor(tool): a tool says for itself whether it can share a batch
The batch planner kept a table of six names that never joined a parallel
run. All six return ReadOnly() == true, which is the whole problem: one
bool was answering two unrelated questions. ReadOnly says a call needs no
approval, because it touches no file and starts no process; batching asks
something else entirely, and for complete_step, todo_write, compress, wait
and bash_output the two answers differ.

So the second question becomes its own contract. Each of the five declares
Sequential with the reason it cannot share a batch — a sign-off advances the
task list, a fold rewrites what the batch is reading, a collector reads what
an earlier call in the reply started.

use_capability was in that table because it "may resolve to a real MCP
writer". It now answers for what it proxies: a directory read shares a
batch, a call is its target's catalog entry, and inspect (which can start a
server), decline (which writes the ledger) and an id the catalog cannot
resolve keep their place. Nothing loosens the writer path — an MCP tool
without an explicit readOnlyHint is already false. What this buys is the
case the name table could not express: several read-only capability calls
in one reply now run together instead of one after another.

The four batch tests asserted the fake's contract rather than the shipped
one, which is exactly how a name table survives being replaced by one, so
they take the real registry. Two guards in builtin fail if a tool stops
declaring Sequential, or if an ordinary reader picks it up; both were
checked by breaking the declaration and watching each go red.
2026-08-22 20:21:19 -07:00
778 changed files with 37063 additions and 7323 deletions

No files matched your search

+5
View File
@@ -432,6 +432,11 @@ jobs:
- name: repo standards
run: go run ./tools/repolint
# The paths the project declared sensitive, held to the coverage they
# already have. It runs only those packages' tests, not the full suite.
- name: sensitive-path coverage
run: go run ./tools/covergate
# `make lint-install` reads the same file, so a local run and this job
# cannot drift onto different linter versions.
- id: golangci
+4 -1
View File
@@ -78,5 +78,8 @@ site/.generated/
# Committed pointer at REASONIX.md; overrides a global CLAUDE.md ignore rule.
!/CLAUDE.md
# Go build output left in the tree by `go build ./tools/...`.
# Go build output left in the tree by `go build ./tools/...`. The ~ form is
# what a linker leaves behind when it cannot replace a binary that is still
# running, which on Windows is every rebuild while Studio is open.
*.exe
*.exe~
+11 -1
View File
@@ -9,7 +9,7 @@ GOEXE := $(shell go env GOEXE)
# One pin for the Makefile and the CI lint job; see .github/workflows/ci.yml.
GOLANGCI_VERSION := $(shell cat .golangci-version)
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update check test studio-test sdk-test sdk-test-race hooks cross clean studio
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update check test studio-test sdk-test sdk-test-race hooks cross clean studio
build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
@@ -57,6 +57,16 @@ check:
lint-update:
go run ./tools/repolint -update
# The paths the project declared sensitive, held to the coverage they already
# have. It runs their tests, so it is its own target rather than part of the
# fast `make lint`. -update records the current numbers; lowering one needs
# -allow-drop, the way widening a repolint budget does.
coverage-gate:
go run ./tools/covergate
coverage-gate-update:
go run ./tools/covergate -update
# Linting one GOOS leaves every //go:build windows and darwin file unchecked.
lint-cross:
@for t in "linux ." "darwin ." "windows ." "linux desktop" "windows desktop"; do \
+15
View File
@@ -50,6 +50,13 @@ agent. It is the Reasonix analog of Claude Code's CLAUDE.md.
sentinel the wrapping preserves; `error-text` fails a new match against an
error message, following it one hop into a local because storing the text
first is what the direct form turns into.
- A wire type the desktop re-declares by hand is held to the kernel's by
`wire-parity`, both directions: a field one side sends and the other cannot
read fails, and so does one it reads that nothing sends. The two are compiled
by different toolchains and meet only over JSON, so nothing else in the tree
notices when one of them grows a field. The pairs are declared in
`tools/repolint/wireparity.go`, never inferred — no spelling tells a mirrored
contract from a struct that merely carries json tags.
## Comments
@@ -163,3 +170,11 @@ out of how a path is spelled, which cannot tell `internal/auth` from
- sensitive: internal/plugin/**
- sensitive: internal/pluginpkg/**
- sensitive: internal/netclient/**
One declaration, two effects: the same list is also the coverage gate's subject.
`make coverage-gate` holds each path to the coverage it already has, because
when this was written these paths sat *below* the tree's median rather than
above it — the code deciding what the agent may execute was its least-tested
part. Floors ratchet like repolint's budgets: raising one is free, lowering one
needs `-allow-drop` and a reason in the PR. Adding a `sensitive:` path gets both
effects; there is no second list to keep in sync.
+35 -1
View File
@@ -1,6 +1,6 @@
# Reasonix Benchmarks
Three harnesses live under `benchmarks/`; `cmd/e2ebench` also exposes a
Five harnesses live under `benchmarks/`; `cmd/e2ebench` also exposes a
SWE-bench Verified mode:
- `e2e/` — the committed end-to-end task suite, driven by
@@ -13,6 +13,12 @@ SWE-bench Verified mode:
- `compaction/` — CompactionBench: grows a session one generation at a time and
folds it after each, measuring what repeated compaction costs and what it
loses. See [CompactionBench](#compactionbench) below.
- `upstream-edge/` — a shape-instructed pair of fleet tasks for pricing what a
`depends_on` edge carries, against the arm where it only orders. See
[Pricing the run graph](#pricing-the-run-graph) below.
- `fanout-width/` — a shape-instructed fleet whose members share nothing, for
pricing what running them at the same time is worth. See
[Pricing the run graph](#pricing-the-run-graph) below.
## Directory layout
@@ -417,6 +423,34 @@ marginal-utility line (`accuracy +X.Xpp · wall/task +Y.Ys`), and — when tasks
carry `class` labels — a per-class breakdown, so a subsystem's uplift and
latency cost can be judged per task class instead of globally.
## Pricing the run graph
Delegation makes three separate claims, and one number cannot settle them. Each
has its own arm, and all three read the same **Fan-out** section of the report —
folded from the run graph, which is the only record of a scheduling wait:
| Claim | Arm |
| --- | --- |
| Delegating at all beats one agent | `-ablate subagent` |
| A `depends_on` edge is worth its payload, not only its order | `-ablate upstream` over `benchmarks/upstream-edge` |
| Running members side by side beats running them in turn | `max_subagent_concurrency = 1` over `benchmarks/fanout-width` |
```sh
go run ./cmd/e2ebench -suite benchmarks/upstream-edge -json control.json
go run ./cmd/e2ebench -suite benchmarks/upstream-edge -ablate upstream -json ablated.json
go run ./cmd/e2ebench -mode compare control.json ablated.json
```
The section reports work against wall (the speed-up the shape bought), the
critical path (the floor the declared dependencies impose — wall above it is
scheduling loss, not shape), and slot wait (what the concurrency ceiling cost
members that had nothing left to wait for).
Read all three axes before calling an arm better: a fan-out that halves wall
clock while doubling tokens wins one and loses another. The arm passes when its
solve rate is no worse, its wall clock is strictly lower, and its tokens stay
inside the budget the comparison declares.
## SWE-bench Verified mode
`e2ebench` can also run the agent inside the official SWE-bench evaluation
@@ -0,0 +1,6 @@
def median(values):
ordered = sorted(values)
mid = len(ordered) // 2
if len(ordered) % 2:
return ordered[mid]
return (ordered[mid - 1] + ordered[mid]) / 2
+26
View File
@@ -0,0 +1,26 @@
# fanout-width
A one-task suite for measuring what running a fan-out's members at the same
time is worth, against a serialised arm where the same graph runs one member at
a time.
It lives outside `benchmarks/e2e` for the same reason `upstream-edge` does: the
prompt fixes the graph shape, which the shared corpus deliberately never does.
This measures what the concurrency bought *given* a fan-out, not how often a
model reaches for one.
The three research items share nothing, so nothing but the scheduler decides
whether they overlap. Serialising them needs no ablation flag — the session
concurrency ceiling already does it:
```sh
go run ./cmd/e2ebench -suite benchmarks/fanout-width -bin <reasonix> -json wide.json
# then, with max_subagent_concurrency = 1 in the arm's config:
go run ./cmd/e2ebench -suite benchmarks/fanout-width -bin <reasonix> -json serial.json
go run ./cmd/e2ebench -mode compare wide.json serial.json
```
Read the **Fan-out** section: the wide arm should report work well above wall
(members overlapped) and near-zero slot wait, the serial arm work equal to wall
and a slot wait that accounts for the difference. Both must still solve the
task — a concurrency win that costs accuracy is not a win.
@@ -0,0 +1,13 @@
prompt = """Answer using the fleet tool with exactly four items, and do not read any project files yourself.
Item one: id "alpha", read_only true. Prompt it to report the marker string the alpha subsystem actually uses at runtime. Ten markers are present in alpha/ and only the live module's is the answer.
Item two: id "beta", read_only true. Prompt it to report the marker string the beta subsystem actually uses at runtime, under the same rule.
Item three: id "gamma", read_only true. Prompt it to report the marker string the gamma subsystem actually uses at runtime, under the same rule.
Item four: id "report", depends_on ["alpha", "beta", "gamma"], write_paths ["ANSWER.md"]. Prompt it to write ANSWER.md in the workspace root with exactly three lines, `alpha=<marker>`, `beta=<marker>` then `gamma=<marker>`.
The first three items must not declare depends_on: nothing one of them finds decides anything another needs."""
class = "delegation"
timeout_sec = 900
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -e
# Three markers from three subsystems that share nothing. The shape is the
# point: a fan-out that runs them one at a time answers exactly as well and
# takes three times as long, which is what the wall-clock arm is asking about.
test -f ANSWER.md
grep -qE '^[[:space:]]*alpha=QORVEX-7741[[:space:]]*$' ANSWER.md
grep -qE '^[[:space:]]*beta=MIRALD-3208[[:space:]]*$' ANSWER.md
grep -qE '^[[:space:]]*gamma=TESSIK-9155[[:space:]]*$' ANSWER.md
@@ -0,0 +1,6 @@
# three subsystems
`alpha/`, `beta/` and `gamma/` are independent: nothing one holds decides
anything in another. Each keeps ten modules and retires all but one; the live
module is named in that subsystem's `entry.py`, and only the live module's
marker is the one the service uses.
@@ -0,0 +1,8 @@
"""retry budget carried by module a01."""
MARKER = "VANTOR-1102-1"
def apply(state):
state["alpha"] = MARKER
return state
@@ -0,0 +1,8 @@
"""retry budget carried by module a02."""
MARKER = "KELBRIS-8830-2"
def apply(state):
state["alpha"] = MARKER
return state
@@ -0,0 +1,8 @@
"""retry budget carried by module a03."""
MARKER = "ORNIDAE-4417-3"
def apply(state):
state["alpha"] = MARKER
return state
@@ -0,0 +1,8 @@
"""retry budget carried by module a04."""
MARKER = "PLYSSA-6624-4"
def apply(state):
state["alpha"] = MARKER
return state
@@ -0,0 +1,8 @@
"""retry budget carried by module a05."""
MARKER = "HAVREK-2093-5"
def apply(state):
state["alpha"] = MARKER
return state
@@ -0,0 +1,8 @@
"""retry budget carried by module a06."""
MARKER = "DUNMIRE-5518-6"
def apply(state):
state["alpha"] = MARKER
return state
@@ -0,0 +1,8 @@
"""retry budget carried by module a07."""
MARKER = "QORVEX-7741"
def apply(state):
state["alpha"] = MARKER
return state
@@ -0,0 +1,8 @@
"""retry budget carried by module a08."""
MARKER = "ZEPHOR-3376-8"
def apply(state):
state["alpha"] = MARKER
return state
@@ -0,0 +1,8 @@
"""retry budget carried by module a09."""
MARKER = "CALTRIX-9042-9"
def apply(state):
state["alpha"] = MARKER
return state
@@ -0,0 +1,8 @@
"""retry budget carried by module a10."""
MARKER = "MORVANE-7715-10"
def apply(state):
state["alpha"] = MARKER
return state
@@ -0,0 +1,12 @@
"""Entry point for the alpha subsystem.
Exactly one module below is live; the rest are retained for rollback and their
markers are stale. LIVE names the module whose marker the service actually uses.
"""
LIVE = "a07"
def marker():
module = __import__("alpha." + LIVE, fromlist=["MARKER"])
return module.MARKER
@@ -0,0 +1,8 @@
"""ledger seal carried by module b01."""
MARKER = "VANTOR-1102-1"
def apply(state):
state["beta"] = MARKER
return state
@@ -0,0 +1,8 @@
"""ledger seal carried by module b02."""
MARKER = "KELBRIS-8830-2"
def apply(state):
state["beta"] = MARKER
return state
@@ -0,0 +1,8 @@
"""ledger seal carried by module b03."""
MARKER = "MIRALD-3208"
def apply(state):
state["beta"] = MARKER
return state
@@ -0,0 +1,8 @@
"""ledger seal carried by module b04."""
MARKER = "ORNIDAE-4417-4"
def apply(state):
state["beta"] = MARKER
return state
@@ -0,0 +1,8 @@
"""ledger seal carried by module b05."""
MARKER = "PLYSSA-6624-5"
def apply(state):
state["beta"] = MARKER
return state
@@ -0,0 +1,8 @@
"""ledger seal carried by module b06."""
MARKER = "HAVREK-2093-6"
def apply(state):
state["beta"] = MARKER
return state
@@ -0,0 +1,8 @@
"""ledger seal carried by module b07."""
MARKER = "DUNMIRE-5518-7"
def apply(state):
state["beta"] = MARKER
return state
@@ -0,0 +1,8 @@
"""ledger seal carried by module b08."""
MARKER = "ZEPHOR-3376-8"
def apply(state):
state["beta"] = MARKER
return state
@@ -0,0 +1,8 @@
"""ledger seal carried by module b09."""
MARKER = "CALTRIX-9042-9"
def apply(state):
state["beta"] = MARKER
return state
@@ -0,0 +1,8 @@
"""ledger seal carried by module b10."""
MARKER = "MORVANE-7715-10"
def apply(state):
state["beta"] = MARKER
return state
@@ -0,0 +1,12 @@
"""Entry point for the beta subsystem.
Exactly one module below is live; the rest are retained for rollback and their
markers are stale. LIVE names the module whose marker the service actually uses.
"""
LIVE = "b03"
def marker():
module = __import__("beta." + LIVE, fromlist=["MARKER"])
return module.MARKER
@@ -0,0 +1,12 @@
"""Entry point for the gamma subsystem.
Exactly one module below is live; the rest are retained for rollback and their
markers are stale. LIVE names the module whose marker the service actually uses.
"""
LIVE = "g09"
def marker():
module = __import__("gamma." + LIVE, fromlist=["MARKER"])
return module.MARKER
@@ -0,0 +1,8 @@
"""dispatch key carried by module g01."""
MARKER = "VANTOR-1102-1"
def apply(state):
state["gamma"] = MARKER
return state
@@ -0,0 +1,8 @@
"""dispatch key carried by module g02."""
MARKER = "KELBRIS-8830-2"
def apply(state):
state["gamma"] = MARKER
return state
@@ -0,0 +1,8 @@
"""dispatch key carried by module g03."""
MARKER = "ORNIDAE-4417-3"
def apply(state):
state["gamma"] = MARKER
return state
@@ -0,0 +1,8 @@
"""dispatch key carried by module g04."""
MARKER = "PLYSSA-6624-4"
def apply(state):
state["gamma"] = MARKER
return state
@@ -0,0 +1,8 @@
"""dispatch key carried by module g05."""
MARKER = "HAVREK-2093-5"
def apply(state):
state["gamma"] = MARKER
return state
@@ -0,0 +1,8 @@
"""dispatch key carried by module g06."""
MARKER = "DUNMIRE-5518-6"
def apply(state):
state["gamma"] = MARKER
return state
@@ -0,0 +1,8 @@
"""dispatch key carried by module g07."""
MARKER = "ZEPHOR-3376-7"
def apply(state):
state["gamma"] = MARKER
return state
@@ -0,0 +1,8 @@
"""dispatch key carried by module g08."""
MARKER = "CALTRIX-9042-8"
def apply(state):
state["gamma"] = MARKER
return state
@@ -0,0 +1,8 @@
"""dispatch key carried by module g09."""
MARKER = "TESSIK-9155"
def apply(state):
state["gamma"] = MARKER
return state
@@ -0,0 +1,8 @@
"""dispatch key carried by module g10."""
MARKER = "MORVANE-7715-10"
def apply(state):
state["gamma"] = MARKER
return state
+31
View File
@@ -0,0 +1,31 @@
# Generated training corpus
Tasks here exist to produce training data, not to measure anything. The e2e
suite is the instrument: mixing generated tasks into it would change what a
cross-version comparison means, so the two stay apart.
## Bundle contract
tasks/<id>/
task.toml prompt, class, timeout_sec
verify.sh the grader; run from the workspace root
workdir/ the seed the agent starts from
solution/ the reference fix, overlaid on workdir/
Both halves of the grader are enforced by `cmd/e2ebench/corpus_test.go`:
- `TestSolvableCorpusSeedsMustNotGradeClean` — verify.sh must **fail** on the
pristine seed, or the task scores the same whether it was solved or ignored.
- `TestCorpusGradersPassTheReferenceSolution` — verify.sh must **pass** with
solution/ applied, or no attempt can ever satisfy it. `solution/` is optional
in the hand-authored corpora and **required** here.
A generated task that fails either gate is deleted, never patched into the
baseline.
## Regenerating
./generate.sh <count>
Candidates are produced one per specification line in `specs.txt`, validated by
the gates above, and only survivors are kept.
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Produce candidate task bundles from specs.txt. Validation is the corpus test,
# not this script: a candidate is kept only if `go test -run Corpus` accepts it.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
bin="${REASONIX_BIN:-reasonix}"
limit="${1:-0}"
staging="$(mktemp -d)"
trap 'rm -rf "$staging"' EXIT
read -r -d '' brief <<'BRIEF' || true
Author one benchmark task bundle in the current directory. Create exactly:
task.toml prompt = "<what the agent must do>", class = "<CLASS>", timeout_sec = 300
verify.sh bash grader, `set -e`, python3 stdlib only
workdir/ the seed files the agent starts from
solution/ the reference fix, files that overlay workdir/
verify.sh runs from a directory holding workdir/'s contents at the top level,
so it references `foo.py`, never `workdir/foo.py`.
Two hard requirements. Check both yourself with real commands before you finish:
1. Copy workdir/ to a scratch directory, copy verify.sh beside it, run it.
It MUST fail. A grader that passes on the seed measures nothing.
2. Do it again but also copy solution/ over the top, and run it.
It MUST pass. A grader nothing can satisfy trains a model to give up.
Grade the requirement, not the implementation: assert on public behaviour and
values, never on internal helper names, line counts, or file layout beyond what
task.toml asks for. No network, no pip, no clock or randomness the grader
cannot control. task.toml's prompt must be solvable from workdir/ alone and
must not reveal the fix.
The subject is: SUBJECT
BRIEF
n=0
while IFS='|' read -r id class topic; do
[ -n "${id:-}" ] || continue
case "$id" in \#*) continue ;; esac
if [ "$limit" -gt 0 ] && [ "$n" -ge "$limit" ]; then break; fi
n=$((n + 1))
if [ -d "$repo/benchmarks/train/tasks/$id" ] && [ "${FORCE:-0}" != "1" ]; then
echo "[$n] $id — already generated; FORCE=1 to redo"
continue
fi
work="$staging/$id"
mkdir -p "$work"
prompt="${brief/SUBJECT/$topic}"
prompt="${prompt//<CLASS>/$class}"
echo "[$n] $id ($class)"
( cd "$work" && "$bin" run -y --print "$prompt" >/dev/null 2>&1 ) || {
echo " agent run failed; skipped"; continue; }
if [ ! -f "$work/task.toml" ] || [ ! -f "$work/verify.sh" ]; then
echo " incomplete bundle; skipped"; continue
fi
rm -rf "${repo:?}/benchmarks/train/tasks/$id"
mkdir -p "$repo/benchmarks/train/tasks"
cp -R "$work" "$repo/benchmarks/train/tasks/$id"
done < "$here/specs.txt"
echo
echo "Validating with the corpus gates; failures are deleted."
cd "$repo"
go test ./cmd/e2ebench/ -run 'Corpus' -json 2>/dev/null |
python3 "$here/prune.py"
+47
View File
@@ -0,0 +1,47 @@
"""Delete generated tasks the corpus gates rejected.
Reads `go test -json` on stdin. A task survives only if no gate failed on it —
a rejected bundle is removed rather than patched, so the baseline never learns
to tolerate a grader that measures nothing.
"""
import json
import os
import shutil
import sys
TASKS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tasks")
failed, passed, why = set(), set(), {}
for line in sys.stdin:
try:
ev = json.loads(line)
except ValueError:
continue
name = ev.get("Test") or ""
parts = name.split("/")
# Only the train subtests name a generated task: Test/<corpus>/<id>.
if len(parts) != 3 or parts[1] != "train":
continue
task = parts[2]
if ev.get("Action") == "fail":
failed.add(task)
elif ev.get("Action") == "pass":
passed.add(task)
elif ev.get("Action") == "output":
# Keep the assertion message, not the "--- FAIL:" banner around it.
text = ev.get("Output", "").strip()
if text.startswith(("---", "===", "ok ", "FAIL")) or ".go:" not in text:
continue
if task not in why:
why[task] = text.split(": ", 1)[-1][:110]
for task in sorted(failed):
path = os.path.join(TASKS, task)
shutil.rmtree(path, ignore_errors=True)
print("rejected %-24s %s" % (task, why.get(task, "gate failed")))
kept = sorted(passed - failed)
print("\nkept %d, rejected %d" % (len(kept), len(failed)))
for task in kept:
print(" " + task)
sys.exit(0)
+12
View File
@@ -0,0 +1,12 @@
gen-interval-merge|atomic-bugfix|merging overlapping half-open intervals
gen-lru-evict|atomic-bugfix|an LRU cache that evicts the wrong entry
gen-csv-quotes|multi-file-bugfix|a CSV reader mishandling quoted fields that contain the separator
gen-money-round|multi-file-bugfix|half-even rounding applied inconsistently across two modules
gen-retry-cap|failing-test-diagnosis|a retry helper whose exponential backoff overruns its cap
gen-topo-cycle|failing-test-diagnosis|a topological sort that loops on a cycle instead of reporting it
gen-semver-cmp|codegen|a semantic-version comparator including pre-release ordering
gen-path-normalize|codegen|a POSIX path normalizer handling dot, dot-dot and redundant separators
gen-config-merge|refactor|three copies of nested-dict merging collapsed into one helper
gen-validator-split|refactor|separating validation from I/O in a record loader
gen-ratelimit-window|api-integration|a sliding-window rate limiter driven by an injected clock
gen-audit-imports|read-only-output|reporting which modules import which, changing nothing
@@ -0,0 +1,10 @@
app/config -> os
app/main -> app.config
app/main -> app.models
app/main -> app.utils
app/models -> dataclasses.dataclass
app/utils -> app.models.Item
app/utils -> json
app/utils -> zoneinfo
scripts/standalone -> app.main.run
scripts/standalone -> sys
@@ -0,0 +1,17 @@
prompt = """
Analyze the Python project in this directory and produce a module import report. The project contains Python source files under `app/` and `scripts/`. Do not execute any code, do not install anything, and do not modify, delete, or add to any existing file: the only file you may create is `import_report.txt` at the project root.
For every `.py` file under the project root, list every module or name it imports. Write the result to `import_report.txt`, one line per imported name, in this exact format:
<importing module> -> <imported module>
where:
- `<importing module>` is the file's path relative to the project root, using `/` as the separator and dropping the `.py` extension (for example, `app/main` or `scripts/standalone`).
- `<imported module>` is the dotted path of the imported name: for `import x.y`, write `x.y`; for `import x.y as z`, write `x.y`; for `from x import y`, write `x.y`; for `from x import y, z`, write one line for `x.y` and one for `x.z`.
Include every import statement in every file, no matter where it appears (module top level, inside a function, or inside a `try`/`except`). Ignore text that merely mentions "import" inside a comment or a string literal. Sort the final lines alphabetically.
When you are done, `import_report.txt` must be the only new file in the project.
"""
class = "read-only-output"
timeout_sec = 300
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Grader for the "module import report" task.
# Runs from a directory holding the project contents at the top level.
set -euo pipefail
cd "$(dirname "$0")"
python3 - <<'PY'
import ast
import hashlib
import pathlib
import sys
root = pathlib.Path(".")
EXPECTED = [
"app/config -> os",
"app/main -> app.config",
"app/main -> app.models",
"app/main -> app.utils",
"app/models -> dataclasses.dataclass",
"app/utils -> app.models.Item",
"app/utils -> json",
"app/utils -> zoneinfo",
"scripts/standalone -> app.main.run",
"scripts/standalone -> sys",
]
# sha256 of the seed files; any modification to the project source fails.
EXPECTED_HASHES = {
"app/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"app/config.py": "7d5203d285bcc073547f9cb9648ef59b8269ed5b40f6c081dafb99fc6dc3cdde",
"app/main.py": "7a795061ccac9e67961dee830344473fcc1ce3844a6655956c8443f3ea81007b",
"app/models.py": "8d4198656a4e40194a278c3aeb7dcde3f8685d860821c9397a9171ef654286c8",
"app/utils.py": "c54cfb01d46373e7460918b3a04d764227f6d15886dd1252977afcaad21643ea",
"scripts/standalone.py": "f2889249515f36757ba3bb8840b9b16329ccececb49fecaefc369875ea1e29c3",
}
def imports_in(path):
tree = ast.parse(path.read_text())
found = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
found.add(alias.name)
elif isinstance(node, ast.ImportFrom) and node.level == 0:
for alias in node.names:
found.add(f"{node.module}.{alias.name}")
return found
failures = []
report_path = root / "import_report.txt"
if not report_path.exists():
print("VERIFY FAILED: import_report.txt was not created at the project root")
sys.exit(1)
lines = [ln.strip() for ln in report_path.read_text().splitlines() if ln.strip()]
if lines != sorted(EXPECTED):
failures.append("import_report.txt content is wrong (wrong lines, wrong order, or duplicates)")
report_set = set(lines)
print(" missing:", sorted(set(EXPECTED) - report_set))
print(" extra: ", sorted(report_set - set(EXPECTED)))
for path, expected_hash in EXPECTED_HASHES.items():
p = root / path
if not p.exists():
failures.append(f"{path} is missing (a source file was deleted)")
continue
if hashlib.sha256(p.read_bytes()).hexdigest() != expected_hash:
failures.append(f"{path} was modified; the task forbids changing source files")
scan = {}
for p in sorted(root.rglob("*.py")):
rel = p.relative_to(root).as_posix()
if rel.endswith(".py"):
rel = rel[:-3]
scan.setdefault(rel, set()).update(imports_in(p))
reported = {}
for ln in lines:
importer, sep, imported = ln.partition(" -> ")
if not sep:
failures.append(f"malformed report line: {ln!r}")
continue
reported.setdefault(importer, set()).add(imported)
for importer in sorted(set(scan) | set(reported)):
if reported.get(importer, set()) != scan.get(importer, set()):
failures.append(f"report for {importer!r} does not match the imports actually present in the tree")
if failures:
print("VERIFY FAILED:")
for f in failures:
print(" -", f)
sys.exit(1)
print("VERIFY PASSED: import_report.txt is correct and no source file was changed")
PY
Whitespace-only changes.
@@ -0,0 +1,13 @@
"""Application configuration."""
import os
# Historical note: this module used to import dotenv; today it needs only os.
DEFAULT_TIMEOUT = 30
def get_settings():
return {
"debug": os.environ.get("APP_DEBUG", "0") == "1",
"timeout": DEFAULT_TIMEOUT,
}
@@ -0,0 +1,10 @@
"""Entry point wiring."""
import app.models as models
from app import config, utils
def run() -> str:
settings = config.get_settings()
item = models.Item("widget", settings["timeout"])
return utils.to_json(item)
@@ -0,0 +1,11 @@
"""Data models for the sample application."""
from dataclasses import dataclass
@dataclass
class Item:
"""A product item."""
name: str
price: float
@@ -0,0 +1,16 @@
"""Serialization helpers."""
import json
from app.models import Item
try:
import zoneinfo
except ImportError: # pragma: no cover
zoneinfo = None
def to_json(item: Item) -> str:
# The word "import" appears in this comment, but it is not an import.
reminder = "do not import anything new in this file"
return json.dumps({"name": item.name, "reminder": reminder})
@@ -0,0 +1,14 @@
#!/usr/bin/env python3
"""Standalone runner script."""
import sys
from app.main import run
def main() -> int:
return 0 if run() else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,43 @@
"""Configuration merging for three call sites.
merger.py historically grew one copy of the nested-dict merge logic per
call site: merge_config (application config defaults), merge_settings
(user settings), and merge_profile (user profile). All three now delegate
to the single shared helper ``_deep_merge``. Each public function returns a
new merged dict and must not mutate either input.
Merging rules (identical for all three functions):
* nested dicts are merged recursively when both the base value and the
override value are dicts;
* on conflict the override wins — including when the override value is
``None`` or ``0``;
* a non-dict override replaces the base value entirely;
* a key present only in the override is added.
"""
def _deep_merge(base, override):
"""Return a new dict: ``override`` deep-merged over ``base``."""
merged = dict(base)
for key, value in override.items():
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
merged[key] = _deep_merge(merged[key], value)
else:
merged[key] = value
return merged
def merge_config(base, override):
"""Merge ``override`` into a copy of ``base`` and return the result."""
return _deep_merge(base, override)
def merge_settings(base, override):
"""Merge ``override`` into a copy of ``base`` and return the result."""
return _deep_merge(base, override)
def merge_profile(base, override):
"""Merge ``override`` into a copy of ``base`` and return the result."""
return _deep_merge(base, override)
@@ -0,0 +1,25 @@
prompt = '''
merger.py contains three public functions — merge_config, merge_settings, and
merge_profile — that are near-identical copies of the same nested-dict merge.
The copies have drifted, so callers can get different results depending on
which function they call.
Refactor merger.py so the three public functions share ONE implementation:
extract the recursive deep-merge logic into a single private helper and make
each public function delegate to it.
Keep the behaviour documented in the module docstring for every function:
- nested dicts are merged recursively when both the base value and the
override value are dicts;
- on conflict the override wins, including when the override value is
None or 0;
- a non-dict override replaces the base value entirely;
- a key present only in the override is added;
- neither the base nor the override input is mutated.
Do not change the public function names or their signatures, and do not add
new dependencies or files.
'''
class = "refactor"
timeout_sec = 300
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Grader: the three public merge functions must all implement the same
# recursive deep-merge (override wins, non-dict override replaces, inputs
# never mutated). Grades public behaviour only; no knowledge of internals.
#
# Runs from a directory that contains the agent's merger.py at the top level.
set -euo pipefail
cd "$(dirname "$0")"
python3 - <<'PY'
import copy
import merger
FUNCTIONS = (merger.merge_config, merger.merge_settings, merger.merge_profile)
def reference(base, override):
"""Spec: recursive deep merge, override wins, non-dict override replaces."""
result = dict(base)
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = reference(result[key], value)
else:
result[key] = value
return result
CASES = [
({}, {}),
({"a": 1}, {"b": 2}),
({"a": 1}, {"a": 2}),
({"a": 0}, {"a": 1}),
({"a": 1}, {"a": 0}),
({"a": None}, {"a": 1}),
({"a": 1}, {"a": None}),
({"a": {"b": 1, "c": 2}}, {"a": {"b": 3}}),
({"a": {"b": 1}}, {"a": 5}),
({"a": 5}, {"a": {"b": 1}}),
({"a": {}}, {"a": {"b": 1}}),
({"a": {"b": {"c": 1}}}, {"a": {"b": {"c": 2, "d": 3}}}),
({"x": {"y": {"z": [1, 2]}}}, {"x": {"y": {"z": [3]}}}),
({"a": {"b": 1, "c": 2}, "d": [1, 2]}, {"a": {"b": 3}, "d": [9]}),
]
def cases():
for base, override in CASES:
yield copy.deepcopy(base), copy.deepcopy(override)
# 1) Every public function must match the spec.
for fn in FUNCTIONS:
for base, override in cases():
got = fn(copy.deepcopy(base), copy.deepcopy(override))
expected = reference(copy.deepcopy(base), copy.deepcopy(override))
if got != expected:
raise AssertionError(
"%s(%r, %r) returned %r, expected %r"
% (fn.__name__, base, override, got, expected)
)
# 2) The three call sites must agree with each other on every case.
for base, override in cases():
first = FUNCTIONS[0](copy.deepcopy(base), copy.deepcopy(override))
for fn in FUNCTIONS[1:]:
other = fn(copy.deepcopy(base), copy.deepcopy(override))
if other != first:
raise AssertionError(
"%s disagrees with %s on %r <- %r: %r vs %r"
% (fn.__name__, FUNCTIONS[0].__name__, base, override, other, first)
)
# 3) Neither input may be mutated.
for fn in FUNCTIONS:
for base, override in cases():
before_b, before_o = copy.deepcopy(base), copy.deepcopy(override)
fn(base, override)
if base != before_b or override != before_o:
raise AssertionError("%s mutated its inputs" % fn.__name__)
print("OK: all three functions match the deep-merge spec, agree with each other, and never mutate inputs.")
PY
@@ -0,0 +1,49 @@
"""Configuration merging for three call sites.
merger.py historically grew one copy of the nested-dict merge logic per
call site: merge_config (application config defaults), merge_settings
(user settings), and merge_profile (user profile). Each function returns a
new merged dict and must not mutate either input.
Merging rules (identical for all three functions):
* nested dicts are merged recursively when both the base value and the
override value are dicts;
* on conflict the override wins — including when the override value is
``None`` or ``0``;
* a non-dict override replaces the base value entirely;
* a key present only in the override is added.
"""
def merge_config(base, override):
"""Merge ``override`` into a copy of ``base`` and return the result."""
merged = dict(base)
for key, value in override.items():
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
merged[key] = merge_config(merged[key], value)
else:
merged[key] = value
return merged
def merge_settings(base, override):
"""Merge ``override`` into a copy of ``base`` and return the result."""
merged = dict(base)
for key, value in override.items():
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
merged[key] = merge_settings(merged[key], value)
else:
merged[key] = value
return merged
def merge_profile(base, override):
"""Merge ``override`` into a copy of ``base`` and return the result."""
merged = dict(base)
for key, value in override.items():
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
merged[key] = value
else:
merged[key] = value
return merged
@@ -0,0 +1,62 @@
"""A small RFC 4180-style CSV reader.
parse_csv() takes CSV text and returns a list of rows, where each row is a
list of field strings. read_csv_file() loads a file and parses its contents.
"""
def parse_csv(text):
"""Parse CSV text into a list of rows.
Handles RFC 4180 quoting: a field wrapped in double quotes may contain
commas and newlines, and a doubled "" inside a quoted field is a literal
quote character. Quotes around a field are not part of its value.
"""
rows = []
row = []
field = []
in_quotes = False
record_started = False
i = 0
n = len(text)
while i < n:
ch = text[i]
if in_quotes:
if ch == '"':
if i + 1 < n and text[i + 1] == '"':
field.append('"')
i += 1
else:
in_quotes = False
else:
field.append(ch)
elif ch == '"' and not field:
in_quotes = True
record_started = True
elif ch == ',':
row.append(''.join(field))
field = []
record_started = True
elif ch in '\r\n':
if record_started:
row.append(''.join(field))
rows.append(row)
row = []
field = []
record_started = False
if ch == '\r' and i + 1 < n and text[i + 1] == '\n':
i += 1
else:
field.append(ch)
record_started = True
i += 1
if record_started:
row.append(''.join(field))
rows.append(row)
return rows
def read_csv_file(path):
"""Read a CSV file and return its rows."""
with open(path, encoding="utf-8") as fh:
return parse_csv(fh.read())
@@ -0,0 +1,12 @@
prompt = """The CSV reader in this project mishandles quoted fields that contain the separator.
`parse_csv(text)` in `csv_reader.py` currently splits each line on `,` and then strips outer double quotes, so a quoted field containing a comma (e.g. `"b,c"`) is broken into several fields, escaped quotes (`""`) inside quoted fields are left as-is, and quoted fields that span a newline are mangled.
Fix `parse_csv` (and any helpers it relies on) so that CSV is parsed correctly:
- a field wrapped in double quotes may contain commas and newlines;
- a `""` inside a quoted field represents a literal `"`;
- the surrounding quotes are not part of the field value.
Keep the public API unchanged: `parse_csv(text)` returns a list of rows, each row a list of strings, and `read_csv_file(path)` returns the same for the contents of a file. Do not change `cli.py`. The behavioural tests in `tests/` express the expected behaviour and should pass once the bug is fixed."""
class = "multi-file-bugfix"
timeout_sec = 300
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Grader: the CSV reader must handle quoted fields that contain the separator.
# Runs from a directory holding the (fixed) workdir contents at the top level,
# so it references csv_reader.py and cli.py directly.
set -e
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
# 1. Behavioural checks against the public API (python3 stdlib only).
python3 - <<'PY'
from csv_reader import parse_csv
import sys
cases = [
# plain data must keep working
("x,y", [["x", "y"]]),
("1,2\n3,4", [["1", "2"], ["3", "4"]]),
("a,,c", [["a", "", "c"]]),
("a,", [["a", ""]]),
("", []),
# quoted fields
('"plain",x', [["plain", "x"]]),
('a,"b,c",d', [["a", "b,c", "d"]]), # separator inside quotes
('"a,b"\n"c,d"', [["a,b"], ["c,d"]]), # separator + quotes on every row
('a,"b\nc",d', [["a", "b\nc", "d"]]), # newline inside quotes
('"he said ""hi""",ok', [['he said "hi"', "ok"]]), # escaped quotes
('a,b\n"c""d",e', [["a", "b"], ['c"d', "e"]]), # escaped quote mid-field
('"",x', [["", "x"]]), # empty quoted field
("1,2\r\n3,4", [["1", "2"], ["3", "4"]]), # CRLF line endings
]
for text, expected in cases:
got = parse_csv(text)
if got != expected:
sys.stderr.write("FAIL: parse_csv(%r) = %r, expected %r\n" % (text, got, expected))
sys.exit(1)
print("parse_csv: %d behavioural cases passed" % len(cases))
PY
# 2. End-to-end check through the CLI with a file on disk.
cat > "$tmpdir/sample.csv" <<'EOF'
name,note
"Ada, Countess of Lovelace","wrote ""notes"""
EOF
expected=$'name|note\nAda, Countess of Lovelace|wrote "notes"'
# Windows' python writes CRLF in text mode, so strip the carriage returns
# before comparing: the grader checks the fields, not the host's line endings.
got="$(python3 cli.py "$tmpdir/sample.csv" | tr -d '\r')"
if [ "$got" != "$expected" ]; then
printf 'FAIL: cli.py printed:\n%s\n\nexpected:\n%s\n' "$got" "$expected" >&2
exit 1
fi
echo "cli.py end-to-end check passed"
echo "ALL CHECKS PASSED"
@@ -0,0 +1,10 @@
# csv-reader
A tiny RFC 4180-style CSV reader.
- `csv_reader.py` — `parse_csv(text)` and `read_csv_file(path)`
- `cli.py` — command-line wrapper: `python3 cli.py FILE`
- `tests/` — behavioural tests: `python3 -m unittest discover -s tests`
Reported issue: quoted fields that contain the separator are not parsed
correctly.
@@ -0,0 +1,22 @@
"""Command-line wrapper around the CSV reader.
Usage: python3 cli.py FILE
Prints each row as pipe-separated fields.
"""
import sys
from csv_reader import read_csv_file
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
if len(argv) != 1:
print("usage: cli.py FILE", file=sys.stderr)
return 2
for row in read_csv_file(argv[0]):
print("|".join(row))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,28 @@
"""A small RFC 4180-style CSV reader.
parse_csv() takes CSV text and returns a list of rows, where each row is a
list of field strings. read_csv_file() loads a file and parses its contents.
"""
def parse_csv(text):
"""Parse CSV text into a list of rows.
Fields are split on commas; a field wrapped in double quotes has its
outer quotes stripped.
"""
rows = []
for line in text.splitlines():
fields = []
for part in line.split(","):
if part.startswith('"') and part.endswith('"'):
part = part[1:-1]
fields.append(part)
rows.append(fields)
return rows
def read_csv_file(path):
"""Read a CSV file and return its rows."""
with open(path, encoding="utf-8") as fh:
return parse_csv(fh.read())
@@ -0,0 +1,28 @@
"""Behavioural tests for the CSV reader (stdlib unittest only)."""
import unittest
from csv_reader import parse_csv
class ParseCsvTest(unittest.TestCase):
def test_plain_rows(self):
self.assertEqual(parse_csv("a,b\nc,d"), [["a", "b"], ["c", "d"]])
def test_quoted_field_with_separator(self):
self.assertEqual(parse_csv('a,"b,c",d'), [["a", "b,c", "d"]])
def test_quoted_field_with_newline(self):
self.assertEqual(parse_csv('a,"b\nc",d'), [["a", "b\nc", "d"]])
def test_escaped_quotes(self):
self.assertEqual(parse_csv('"say ""hi""",x'), [['say "hi"', "x"]])
def test_empty_fields(self):
self.assertEqual(parse_csv("a,,c"), [["a", "", "c"]])
def test_trailing_newline(self):
self.assertEqual(parse_csv("a,b\n"), [["a", "b"]])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,28 @@
"""Merge overlapping half-open intervals."""
def merge_intervals(intervals):
"""Merge overlapping half-open intervals into a minimal list of
disjoint intervals, sorted by start.
Intervals are half-open: [start, end) contains start but not end.
Two intervals overlap when they share at least one real number; in
particular [0, 5) and [5, 9) are adjacent but disjoint and must not
be merged.
>>> merge_intervals([(1, 3), (2, 6), (8, 10), (15, 18)])
[(1, 6), (8, 10), (15, 18)]
>>> merge_intervals([(0, 1), (1, 2)])
[(0, 1), (1, 2)]
"""
if not intervals:
return []
ordered = sorted(intervals)
merged = [ordered[0]]
for start, end in ordered[1:]:
last_start, last_end = merged[-1]
if start < last_end:
merged[-1] = (last_start, max(last_end, end))
else:
merged.append((start, end))
return merged
@@ -0,0 +1,7 @@
prompt = """`interval_merge.py` defines `merge_intervals(intervals)`, which must merge overlapping half-open intervals into a minimal list of disjoint intervals, sorted by start.
Intervals are half-open: `[start, end)` includes `start` but excludes `end`. Two intervals overlap if and only if they share at least one real number. For example, `[0, 5)` and `[3, 8)` overlap, while `[0, 5)` and `[5, 9)` are adjacent but disjoint and must NOT be merged — they must stay separate intervals in the output.
The current implementation contains a bug: for some inputs it merges intervals that do not overlap. Fix `merge_intervals` so that it returns the correct merged list for every input. Inputs are always `(start, end)` pairs with `start < end`; keep the function's name and signature unchanged."""
class = "atomic-bugfix"
timeout_sec = 300
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Grader for the interval-merge task.
# Runs from a directory that holds interval_merge.py at the top level.
set -e
cd "$(dirname "$0")"
python3 - <<'PY'
import sys
sys.path.insert(0, ".")
from interval_merge import merge_intervals
def norm(intervals):
return [tuple(iv) for iv in intervals]
CASES = [
# Basic overlap plus disjoint intervals.
([(1, 3), (2, 6), (8, 10), (15, 18)], [(1, 6), (8, 10), (15, 18)]),
# Adjacent half-open intervals do not overlap and must stay separate.
([(0, 1), (1, 2)], [(0, 1), (1, 2)]),
# Fully contained interval.
([(1, 10), (2, 5)], [(1, 10)]),
# Chain of overlaps collapses to one interval.
([(0, 2), (1, 3), (2, 4)], [(0, 4)]),
# Several adjacent intervals, none overlapping.
([(0, 1), (1, 2), (2, 3)], [(0, 1), (1, 2), (2, 3)]),
# Unsorted input is handled.
([(5, 6), (1, 2), (3, 4)], [(1, 2), (3, 4), (5, 6)]),
# Empty input.
([], []),
# Single interval.
([(2, 3)], [(2, 3)]),
# Overlap sharing the same end.
([(0, 5), (3, 5)], [(0, 5)]),
]
for index, (inp, expected) in enumerate(CASES, start=1):
got = merge_intervals(inp)
if norm(got) != norm(expected):
raise AssertionError(
"case %d: merge_intervals(%r) returned %r; expected %r"
% (index, inp, got, expected)
)
print("OK: all %d cases passed" % len(CASES))
PY
@@ -0,0 +1,28 @@
"""Merge overlapping half-open intervals."""
def merge_intervals(intervals):
"""Merge overlapping half-open intervals into a minimal list of
disjoint intervals, sorted by start.
Intervals are half-open: [start, end) contains start but not end.
Two intervals overlap when they share at least one real number; in
particular [0, 5) and [5, 9) are adjacent but disjoint and must not
be merged.
>>> merge_intervals([(1, 3), (2, 6), (8, 10), (15, 18)])
[(1, 6), (8, 10), (15, 18)]
>>> merge_intervals([(0, 1), (1, 2)])
[(0, 1), (1, 2)]
"""
if not intervals:
return []
ordered = sorted(intervals)
merged = [ordered[0]]
for start, end in ordered[1:]:
last_start, last_end = merged[-1]
if start <= last_end:
merged[-1] = (last_start, max(last_end, end))
else:
merged.append((start, end))
return merged
@@ -0,0 +1,37 @@
"""A fixed-capacity LRU (least recently used) cache.
`get(key)` returns the cached value, or -1 when the key is not cached.
`put(key, value)` stores a value, updating it when the key is already
cached. Once the cache is at capacity, inserting a new key evicts the
least recently used entry.
"""
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.data = {}
self.order = [] # keys ordered from least to most recently used
def _touch(self, key):
"""Move key to the end of the order list (most recently used)."""
if key in self.order:
self.order.remove(key)
self.order.append(key)
def get(self, key):
if key not in self.data:
return -1
self._touch(key)
return self.data[key]
def put(self, key, value):
if key in self.data:
self.data[key] = value
self._touch(key)
return
if len(self.data) >= self.capacity:
victim = self.order.pop(0)
del self.data[victim]
self.data[key] = value
self._touch(key)
@@ -0,0 +1,3 @@
prompt = "A fixed-capacity LRU cache lives in `lru_cache.py`. Its public interface is `LRUCache(capacity)`, `get(key)` (returns the cached value, or -1 when the key is absent), and `put(key, value)` (inserts or updates a key; once the cache is full, inserting a new key evicts one entry). The cache currently evicts the wrong entry: a key that was just read via `get` can still be the one removed when the cache fills, while entries that were not read recently survive. Fix the cache so that a key is evicted only when it is the least recently used entry — reading a key must make it the most recently used. Do not change the public interface; only fix the behavior."
class = "atomic-bugfix"
timeout_sec = 300
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Grader for the LRU cache benchmark.
# Runs from a directory whose top level holds the submitted files
# (i.e. workdir/ contents copied to the top level).
set -e
cd "$(dirname "$0")"
python3 - <<'PY'
import os
import sys
sys.path.insert(0, os.getcwd())
import lru_cache
def check(cond, msg):
if not cond:
raise AssertionError("FAIL: " + msg)
# --- basic get/put behavior ---
c = lru_cache.LRUCache(2)
check(c.get("missing") == -1, "get() of an absent key must return -1")
c.put("a", 1)
c.put("b", 2)
check(c.get("a") == 1, "get() must return the stored value")
check(c.get("b") == 2, "get() must return the stored value")
# --- eviction with no reads: oldest inserted entry goes first ---
c = lru_cache.LRUCache(2)
c.put("a", 1)
c.put("b", 2)
c.put("c", 3)
check(c.get("a") == -1, "with no reads, the oldest inserted entry must be evicted")
check(c.get("b") == 2, "b must still be cached")
check(c.get("c") == 3, "c must still be cached")
# --- reading a key must make it most recently used ---
c = lru_cache.LRUCache(2)
c.put("a", 1)
c.put("b", 2)
check(c.get("a") == 1, "reading a must refresh its recency")
c.put("c", 3) # cache is full: must evict b, not the just-read a
check(c.get("b") == -1, "b is least recently used and must be evicted")
check(c.get("a") == 1, "the just-read a must survive eviction")
check(c.get("c") == 3, "the new key c must be cached")
# --- repeated reads keep an entry alive across evictions ---
c = lru_cache.LRUCache(3)
c.put("a", 1)
c.put("b", 2)
c.put("c", 3)
check(c.get("b") == 2, "reading b must refresh its recency")
c.put("d", 4) # evicts a
check(c.get("a") == -1, "a is least recently used and must be evicted")
check(c.get("b") == 2 and c.get("c") == 3 and c.get("d") == 4,
"b, c and d must all be cached after the first eviction")
check(c.get("b") == 2, "reading b again must refresh its recency")
c.put("e", 5) # must evict c, not the just-read b
check(c.get("c") == -1, "c is least recently used and must be evicted")
check(c.get("b") == 2, "the just-read b must survive eviction")
check(c.get("d") == 4 and c.get("e") == 5, "d and e must be cached")
# --- put() on an existing key updates the value and refreshes recency ---
c = lru_cache.LRUCache(2)
c.put("a", 1)
c.put("b", 2)
c.put("a", 10)
c.put("c", 3) # must evict b
check(c.get("b") == -1, "updated key a is recent; b must be evicted")
check(c.get("a") == 10, "put() on an existing key must update its value")
check(c.get("c") == 3, "the new key c must be cached")
print("all checks passed")
PY
@@ -0,0 +1,36 @@
"""A fixed-capacity LRU (least recently used) cache.
`get(key)` returns the cached value, or -1 when the key is not cached.
`put(key, value)` stores a value, updating it when the key is already
cached. Once the cache is at capacity, inserting a new key evicts the
least recently used entry.
"""
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.data = {}
self.order = [] # keys ordered from least to most recently used
def _touch(self, key):
"""Move key to the end of the order list (most recently used)."""
if key in self.order:
self.order.remove(key)
self.order.append(key)
def get(self, key):
if key not in self.data:
return -1
return self.data[key]
def put(self, key, value):
if key in self.data:
self.data[key] = value
self._touch(key)
return
if len(self.data) >= self.capacity:
victim = self.order.pop(0)
del self.data[victim]
self.data[key] = value
self._touch(key)
@@ -0,0 +1,14 @@
"""Human-facing display of monetary amounts.
Contract (see README.md): every amount is rounded to the nearest cent
using round-half-even (banker's rounding), so an exact tie goes to the
even cent: 0.125 -> 0.12, 0.135 -> 0.14.
"""
from decimal import Decimal, ROUND_HALF_EVEN
def format_amount(value):
"""Format a monetary amount as ``"$<cents>"``, e.g. ``format_amount(0.135) == "$0.14"``."""
rounded = Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)
return "${}".format(rounded)
@@ -0,0 +1,13 @@
"""Posting-side rounding of monetary amounts.
Contract (see README.md): every amount is rounded to the nearest cent
using round-half-even (banker's rounding), so an exact tie goes to the
even cent: 0.125 -> 0.12, 0.135 -> 0.14.
"""
from decimal import Decimal, ROUND_HALF_EVEN
def round_to_cents(value):
"""Round a monetary amount to whole cents, e.g. ``round_to_cents(0.125) == 0.12``."""
return float(Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN))
@@ -0,0 +1,3 @@
prompt = """In the finmath library, every monetary amount must be rounded to the nearest cent using round-half-even (banker's rounding): an amount exactly halfway between two cents goes to the even cent (e.g. 0.125 -> 0.12, 0.135 -> 0.14, 1.225 -> 1.22, 1.235 -> 1.24, 2.675 -> 2.68). The two modules currently round inconsistently: for several exact half-cent inputs, display.format_amount and ledger.round_to_cents disagree with each other and with round-half-even. Fix both modules so they agree with each other and with round-half-even on every input. Keep the public signatures and return types unchanged (format_amount -> str, round_to_cents -> float). The contract tests under tests/ document the expected behaviour; check your fix with `python3 -m unittest discover -s tests`."""
class = "multi-file-bugfix"
timeout_sec = 300
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Grader for the finmath half-even rounding task.
# Runs from a directory that holds the working tree at the top level.
set -euo pipefail
cd "$(dirname "$0")"
python3 - <<'PY'
import unittest
from decimal import Decimal, ROUND_HALF_EVEN
import display
import ledger
CENTS = Decimal("0.01")
def expected(raw):
"""Half-even rounding of the exact decimal given as a string."""
return Decimal(raw).quantize(CENTS, rounding=ROUND_HALF_EVEN)
HALF_CENT_CASES = [
"0.125", "0.135", "0.145",
"1.225", "1.235",
"2.675",
"12.345", "12.355",
"99.995", "1234.565",
]
MISC_CASES = ["0.10", "1.23", "0.999", "4.567", "9.9999", "2.50"]
class TestHalfEvenContract(unittest.TestCase):
def test_display_rounds_half_even(self):
for raw in HALF_CENT_CASES:
self.assertEqual(
display.format_amount(float(raw)),
"${}".format(expected(raw)),
msg=raw,
)
def test_ledger_rounds_half_even(self):
for raw in HALF_CENT_CASES:
self.assertEqual(
ledger.round_to_cents(float(raw)),
float(expected(raw)),
msg=raw,
)
def test_both_modules_agree_on_every_input(self):
for raw in HALF_CENT_CASES + MISC_CASES:
want = float(expected(raw))
self.assertEqual(ledger.round_to_cents(float(raw)), want, msg=raw)
self.assertEqual(display.format_amount(float(raw)), "${}".format(expected(raw)), msg=raw)
def test_negative_ties_round_to_even(self):
self.assertEqual(ledger.round_to_cents(-0.125), -0.12)
if __name__ == "__main__":
unittest.main(verbosity=2)
PY
@@ -0,0 +1,33 @@
# finmath
A small library that rounds monetary amounts to whole cents.
## Rounding contract
Every amount is rounded to the nearest cent using **round-half-even**
(banker's rounding): an amount that falls exactly halfway between two
cents goes to the *even* cent.
| input | rounded |
|---------|---------|
| 0.125 | 0.12 |
| 0.135 | 0.14 |
| 1.225 | 1.22 |
| 1.235 | 1.24 |
| 2.675 | 2.68 |
## Modules
- `display.py` — `format_amount(value) -> str` formats an amount for
display, e.g. `format_amount(0.135) == "$0.14"`.
- `ledger.py` — `round_to_cents(value) -> float` rounds an amount for
posting, e.g. `round_to_cents(0.125) == 0.12`.
Both modules must round **every** input identically to the contract
above; they must never disagree with each other.
## Tests
Run the contract tests with:
python3 -m unittest discover -s tests
@@ -0,0 +1,11 @@
"""Human-facing display of monetary amounts.
Contract (see README.md): every amount is rounded to the nearest cent
using round-half-even (banker's rounding), so an exact tie goes to the
even cent: 0.125 -> 0.12, 0.135 -> 0.14.
"""
def format_amount(value):
"""Format a monetary amount as ``"$<cents>"``, e.g. ``format_amount(0.135) == "$0.14"``."""
return "${:.2f}".format(value)
@@ -0,0 +1,13 @@
"""Posting-side rounding of monetary amounts.
Contract (see README.md): every amount is rounded to the nearest cent
using round-half-even (banker's rounding), so an exact tie goes to the
even cent: 0.125 -> 0.12, 0.135 -> 0.14.
"""
from decimal import Decimal, ROUND_HALF_UP
def round_to_cents(value):
"""Round a monetary amount to whole cents, e.g. ``round_to_cents(0.125) == 0.12``."""
return float(Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
@@ -0,0 +1,38 @@
"""Contract tests for the finmath library.
Run with: python3 -m unittest discover -s tests
"""
import unittest
from decimal import Decimal, ROUND_HALF_EVEN
import display
import ledger
CASES = ["0.125", "0.135", "1.225", "1.235", "2.675", "12.345", "12.355"]
def expected(raw):
return Decimal(raw).quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)
class TestHalfEvenRounding(unittest.TestCase):
def test_display_rounds_half_even(self):
for raw in CASES:
self.assertEqual(
display.format_amount(float(raw)),
"${}".format(expected(raw)),
msg=raw,
)
def test_ledger_rounds_half_even(self):
for raw in CASES:
self.assertEqual(
ledger.round_to_cents(float(raw)),
float(expected(raw)),
msg=raw,
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,20 @@
"""Lexical POSIX path normalization (no filesystem access)."""
def normalize(path):
stack = []
for part in path.split("/"):
if part == "" or part == ".":
continue
if part == "..":
if stack and stack[-1] != "..":
stack.pop()
elif stack or not path.startswith("/"):
stack.append("..")
continue
stack.append(part)
joined = "/".join(stack)
if path.startswith("/"):
return "/" + joined
return joined if joined else "."
@@ -0,0 +1,19 @@
prompt = """Write a function `normalize(path: str) -> str` in `pathutil.py` that returns the canonical POSIX form of the given path. Do not touch the filesystem: normalization is purely lexical.
Rules:
- Treat one or more consecutive `/` as a single `/`, including at the start of the path (`//x` -> `/x`, `///` -> `/`).
- Remove `.` components.
- Resolve `..` components: each `..` removes the nearest preceding component that is not itself `..`. In a relative path, a leading `..` with nothing left to remove is kept (`a/../../b` -> `../b`, `../..` stays `../..`). In an absolute path, `..` at the root is discarded (`/a/../../b` -> `/b`, `/..` -> `/`).
- The result never ends in `/`, except for the root path `/` itself.
- The empty string normalizes to `.`; a relative path that reduces to nothing gives `.` (`a/..` -> `.`); an absolute path that reduces to nothing gives `/` (`/a/..` -> `/`).
- Components that merely contain dots (`a.b`, `..b`, `...`) are ordinary components and are left untouched.
Examples:
normalize("a//b/./c/") -> "a/b/c"
normalize("a/b/../c") -> "a/c"
normalize("../../a/..") -> "../.."
normalize("/a/./b/..") -> "/a"
The grader imports `pathutil` and calls `pathutil.normalize(path)`; `normalize` must be a callable taking a single `str` argument and returning a `str`."""
class = "codegen"
timeout_sec = 300
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
python3 - <<'PY'
import sys
try:
import pathutil
except Exception as exc: # missing file, syntax error, import-time crash
print(f"FAIL: cannot import pathutil: {exc}", file=sys.stderr)
sys.exit(1)
normalize = getattr(pathutil, "normalize", None)
if not callable(normalize):
print("FAIL: pathutil has no callable normalize()", file=sys.stderr)
sys.exit(1)
CASES = [
# identity / ordinary components
("a/b/c", "a/b/c"),
("a b/c d", "a b/c d"),
("a.b/c", "a.b/c"),
("a/..b", "a/..b"),
# redundant separators
("a//b", "a/b"),
("a///b///c", "a/b/c"),
("a//", "a"),
("a/b//c//d", "a/b/c/d"),
("//a", "/a"),
("//", "/"),
("///", "/"),
("/", "/"),
# dot components
("./a", "a"),
("a/.", "a"),
("a/./b", "a/b"),
("a/././b", "a/b"),
(".", "."),
("./", "."),
# dot-dot components
("a/../b", "b"),
("a/b/..", "a"),
("a/..", "."),
("a/b/../", "a"),
("..", ".."),
("../..", "../.."),
("../../a/..", "../.."),
("a/../../b", "../b"),
("a/../..", ".."),
("a/b/c/../../d", "a/d"),
("a/b/../../..", ".."),
("../a/..", ".."),
("/../..", "/"),
("a/./../b/./", "b"),
("a/.../..", "a"),
# absolute paths
("/a/b", "/a/b"),
("/a/../b", "/b"),
("/..", "/"),
("/a/../../b", "/b"),
("/./a", "/a"),
("/a/.", "/a"),
("/a/b/..", "/a"),
("/a/", "/a"),
("/a//b/./c/", "/a/b/c"),
("/a/b/./", "/a/b"),
# empty / trailing slash
("", "."),
("a/b/", "a/b"),
("a/../", "."),
]
failed = 0
for inp, expected in CASES:
try:
got = normalize(inp)
except Exception as exc:
print(f"FAIL: normalize({inp!r}) raised {exc!r}")
failed += 1
continue
if got != expected:
print(f"FAIL: normalize({inp!r}) = {got!r}, expected {expected!r}")
failed += 1
if failed:
print(f"{failed} of {len(CASES)} cases failed", file=sys.stderr)
sys.exit(1)
print(f"OK: {len(CASES)}/{len(CASES)} cases passed")
PY
@@ -0,0 +1,10 @@
"""POSIX path normalization.
`normalize(path)` must return the canonical lexical POSIX form of `path`.
See the task description for the full rules.
"""
def normalize(path):
# TODO: implement POSIX path normalization.
return path
@@ -0,0 +1,30 @@
"""Sliding-window rate limiter driven by an injected clock."""
import time
from collections import deque
class RateLimiter:
"""Rate-limits requests with a sliding time window.
A request is allowed when fewer than `max_requests` requests were recorded
at times strictly more recent than `now - window_seconds`, where `now`
comes from the injected clock (default: `time.time`). Allowed requests are
recorded; denied requests are not.
"""
def __init__(self, max_requests, window_seconds, clock=None):
self.max_requests = max_requests
self.window_seconds = window_seconds
self.clock = clock if clock is not None else time.time
self._timestamps = deque()
def allow(self):
now = self.clock()
cutoff = now - self.window_seconds
while self._timestamps and self._timestamps[0] <= cutoff:
self._timestamps.popleft()
if len(self._timestamps) >= self.max_requests:
return False
self._timestamps.append(now)
return True
@@ -0,0 +1,10 @@
prompt = """In `ratelimiter.py`, implement `RateLimiter`, a sliding-window rate limiter whose only source of time is an injected clock.
Public API (keep it exactly as specified):
- Constructor: `RateLimiter(max_requests: int, window_seconds: float, clock: Callable[[], float] = time.time)`. `max_requests` is the burst limit, `window_seconds` is the length of the window in seconds, and `clock` is an injectable callable that returns the current time in seconds. When `clock` is `None`, the limiter must fall back to `time.time`. Every decision must be derived from the clock's value: do not read the wall clock in any other way and do not use randomness.
- `allow() -> bool` answers and records one request. It must return `True` when fewer than `max_requests` requests have been recorded at times strictly more recent than `now - window_seconds`, where `now` is the value returned by the clock on this call; otherwise it must return `False`. Denied requests are not recorded. A recorded request stops counting once it is `window_seconds` old, so capacity is released continuously as the window slides.
The current implementation in `ratelimiter.py` does not meet this contract. Rewrite it so its behaviour matches the specification above exactly. Behaviour is graded by hidden tests that drive the limiter with a deterministic fake clock.
"""
class = "api-integration"
timeout_sec = 300
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Grader: sliding-window rate limiter driven by an injected clock.
set -euo pipefail
cd "$(dirname "$0")"
python3 - <<'PY'
import ratelimiter
def check(cond, msg):
if not cond:
raise AssertionError(msg)
class FakeClock:
"""Deterministic stand-in for time.time; records every call."""
def __init__(self):
self.t = 0.0
self.calls = 0
def __call__(self):
self.calls += 1
return self.t
# 1. The injected clock is the limiter's only source of time.
clock = FakeClock()
lim = ratelimiter.RateLimiter(5, 1.0, clock=clock)
clock.t = 12.5
check(lim.allow() is True, "first request must be allowed")
check(clock.calls >= 1, "limiter must consult the injected clock")
# 2. Sliding window: a burst crossing a naive fixed-window boundary is denied.
clock = FakeClock()
lim = ratelimiter.RateLimiter(5, 1.0, clock=clock)
for t in (0.90, 0.91, 0.92, 0.93, 0.94):
clock.t = t
check(lim.allow() is True, "request at t=%s must be allowed" % t)
clock.t = 1.0
check(lim.allow() is False, "at t=1.0 all five requests are still inside the window")
# 3. Requests older than window_seconds stop counting.
clock.t = 2.0
check(lim.allow() is True, "requests older than the window must expire")
# 4. Capacity refills gradually as the window slides, not at window boundaries.
clock = FakeClock()
lim = ratelimiter.RateLimiter(3, 1.0, clock=clock)
clock.t = 0.1
check(lim.allow() is True, "request at t=0.1 must be allowed")
clock.t = 0.2
check(lim.allow() is True, "request at t=0.2 must be allowed")
clock.t = 0.3
check(lim.allow() is True, "request at t=0.3 must be allowed")
clock.t = 1.05
check(lim.allow() is False, "at t=1.05 all three requests are still inside the window")
clock.t = 1.11
check(lim.allow() is True, "the t=0.1 request has expired, leaving capacity")
# 5. A request is expired once it is exactly window_seconds old.
clock = FakeClock()
lim = ratelimiter.RateLimiter(2, 5.0, clock=clock)
clock.t = 0.0
check(lim.allow() is True, "first request at t=0 must be allowed")
clock.t = 0.0
check(lim.allow() is True, "second request at t=0 must be allowed")
clock.t = 0.0
check(lim.allow() is False, "third request at t=0 must be denied")
clock.t = 5.0 - 1e-9
check(lim.allow() is False, "t=0 requests are still inside just before t=5.0")
clock.t = 5.0
check(lim.allow() is True, "t=0 requests are expired at exactly t=5.0")
# 6. Without a clock the default (time.time) is used.
lim = ratelimiter.RateLimiter(2, 1e6)
check(lim.allow() is True, "default-clock limiter allows the first request")
check(lim.allow() is True, "default-clock limiter allows the second request")
check(lim.allow() is False, "default-clock limiter denies the third request")
print("PASS")
PY
@@ -0,0 +1,29 @@
"""Sliding-window rate limiter with an injectable clock."""
import time
class RateLimiter:
"""Limits how many requests may pass within a time window."""
def __init__(self, max_requests, window_seconds, clock=None):
self.max_requests = max_requests
self.window_seconds = window_seconds
self.clock = clock or time.time
self._count = 0
self._window_start = None
def allow(self):
now = time.time()
if self._window_start is None:
self._window_start = now
self._count = 1
return True
if now - self._window_start >= self.window_seconds:
self._window_start = now
self._count = 1
return True
if self._count >= self.max_requests:
return False
self._count += 1
return True
@@ -0,0 +1,54 @@
"""Retry helper with exponential backoff.
Public API
----------
retry_call(fn, attempts=3, base_delay=1.0, max_delay=10.0)
Call fn() until it returns without raising. fn() is invoked once
immediately; if it raises, sleep ``backoff_delay(failures, ...)``
seconds and try again, up to ``attempts`` tries in total. When all
``attempts`` tries have failed, raise RetryExhausted. Raises
ValueError if attempts < 1 or either delay bound is not positive.
backoff_delay(attempt, base_delay, max_delay)
Seconds to sleep after ``attempt`` consecutive failures: the
exponential sequence base_delay * 2 ** (attempt - 1), clamped so it
never exceeds max_delay.
RetryExhausted
Exception raised when every try has failed.
The helper sleeps via ``time.sleep`` so callers can intercept or record
the delays by patching the ``time`` module.
"""
import time
class RetryExhausted(Exception):
"""Raised when all ``attempts`` tries have failed."""
def backoff_delay(attempt, base_delay, max_delay):
"""Delay after ``attempt`` failures: base_delay * 2**(attempt-1),
never more than max_delay."""
# Exponential growth, clamped so the delay itself never exceeds
# max_delay.
return min(base_delay * 2 ** (attempt - 1), max_delay)
def retry_call(fn, attempts=3, base_delay=1.0, max_delay=10.0):
if attempts < 1:
raise ValueError("attempts must be >= 1")
if base_delay <= 0 or max_delay <= 0:
raise ValueError("delays must be positive")
failures = 0
while True:
try:
return fn()
except Exception:
failures += 1
if failures == attempts:
raise RetryExhausted(
"all %d attempts failed" % attempts
) from None
time.sleep(backoff_delay(failures, base_delay, max_delay))
@@ -0,0 +1,3 @@
prompt = "The retry helper in retry.py backs off exponentially between retries, but its backoff overruns the configured cap: backoff_delay() returns values above max_delay, and retry_call() actually sleeps longer than max_delay between tries. Diagnose the bug and fix retry.py — keeping the public API (retry_call, backoff_delay, RetryExhausted) unchanged — so that delays grow exponentially with each consecutive failure and every delay is clamped at max_delay. Make `python3 -m unittest test_retry` pass."
class = "failing-test-diagnosis"
timeout_sec = 300
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
#
# Grader for the retry-backoff task.
#
# Runs from a directory where the workdir/ contents sit at the top level
# (retry.py beside this script). Grades public behaviour only: backoff
# delays must grow exponentially with each consecutive failure and must
# never exceed max_delay, and retry_call must actually sleep those
# delays. All checks are independent of the test file shipped with the
# task, so weakening or deleting tests cannot fool the grader.
#
# bash verify.sh # exit 0 on a correct fix, non-zero otherwise
#
set -e
cd "$(dirname "$0")"
python3 - <<'PY'
import importlib.util
import sys
import types
import time as _real_time
def load_retry():
"""Import ./retry.py with a fake time module that records every sleep.
Intercepting the module at load time (via sys.modules) means the
recording works whether retry.py uses ``import time`` or
``from time import sleep``.
"""
recorded = []
fake = types.ModuleType("time")
fake.sleep = lambda d: recorded.append(d)
for name in dir(_real_time):
if not hasattr(fake, name):
setattr(fake, name, getattr(_real_time, name))
old = sys.modules.get("time")
sys.modules["time"] = fake
try:
spec = importlib.util.spec_from_file_location("retry", "retry.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
finally:
if old is not None:
sys.modules["time"] = old
else:
del sys.modules["time"]
return mod, recorded
# --- backoff_delay: exponential growth, clamped at max_delay -----------
mod, _ = load_retry()
seq = [mod.backoff_delay(n, 2.0, 5.0) for n in range(1, 9)]
assert seq == [2.0, 4.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0], seq
# The cap holds even when base_delay alone exceeds max_delay.
assert mod.backoff_delay(1, 7.0, 5.0) == 5.0
assert mod.backoff_delay(2, 7.0, 5.0) == 5.0
# A differently-scaled case: never exceed the cap for many attempts.
for n in range(1, 20):
d = mod.backoff_delay(n, 1.5, 3.0)
assert d <= 3.0, (n, d)
assert mod.backoff_delay(5, 1.5, 3.0) == 3.0
# --- retry_call: must actually sleep the capped sequence ---------------
mod, recorded = load_retry()
calls = []
def flaky():
calls.append(1)
if len(calls) < 4:
raise ValueError("boom")
return "ok"
assert mod.retry_call(flaky, attempts=4, base_delay=2.0, max_delay=5.0) == "ok"
assert len(calls) == 4
assert recorded == [2.0, 4.0, 5.0], recorded
# --- exhaustion, first-try success, argument validation ----------------
mod, recorded = load_retry()
def always_fail():
raise RuntimeError("nope")
try:
mod.retry_call(always_fail, attempts=3, base_delay=0.5, max_delay=2.0)
raise SystemExit("retry_call should have raised RetryExhausted")
except mod.RetryExhausted:
pass
assert len(recorded) == 2
mod, recorded = load_retry()
assert mod.retry_call(lambda: "ok", attempts=3) == "ok"
assert recorded == []
mod, _ = load_retry()
for bad in (dict(attempts=0), dict(base_delay=0.0), dict(max_delay=-1.0)):
try:
mod.retry_call(lambda: 1, **bad)
raise SystemExit("expected ValueError for %r" % (bad,))
except ValueError:
pass
print("verify.sh: PASS")
PY
@@ -0,0 +1,53 @@
"""Retry helper with exponential backoff.
Public API
----------
retry_call(fn, attempts=3, base_delay=1.0, max_delay=10.0)
Call fn() until it returns without raising. fn() is invoked once
immediately; if it raises, sleep ``backoff_delay(failures, ...)``
seconds and try again, up to ``attempts`` tries in total. When all
``attempts`` tries have failed, raise RetryExhausted. Raises
ValueError if attempts < 1 or either delay bound is not positive.
backoff_delay(attempt, base_delay, max_delay)
Seconds to sleep after ``attempt`` consecutive failures: the
exponential sequence base_delay * 2 ** (attempt - 1), clamped so it
never exceeds max_delay.
RetryExhausted
Exception raised when every try has failed.
The helper sleeps via ``time.sleep`` so callers can intercept or record
the delays by patching the ``time`` module.
"""
import time
class RetryExhausted(Exception):
"""Raised when all ``attempts`` tries have failed."""
def backoff_delay(attempt, base_delay, max_delay):
"""Delay after ``attempt`` failures: base_delay * 2**(attempt-1),
never more than max_delay."""
# Clamp the growth factor so the delay never exceeds max_delay.
return base_delay * min(2 ** (attempt - 1), max_delay)
def retry_call(fn, attempts=3, base_delay=1.0, max_delay=10.0):
if attempts < 1:
raise ValueError("attempts must be >= 1")
if base_delay <= 0 or max_delay <= 0:
raise ValueError("delays must be positive")
failures = 0
while True:
try:
return fn()
except Exception:
failures += 1
if failures == attempts:
raise RetryExhausted(
"all %d attempts failed" % attempts
) from None
time.sleep(backoff_delay(failures, base_delay, max_delay))
@@ -0,0 +1,110 @@
"""Tests for retry.py.
The helper must back off exponentially between retries but never sleep
longer than max_delay. These tests record the actual sleeps by loading
retry.py against a fake ``time`` module, so they check what retry_call
really does, not just the delay formula.
Run with: python3 -m unittest test_retry
"""
import importlib.util
import sys
import types
import unittest
import time as _real_time
def load_retry():
"""Import ./retry.py with time.sleep replaced by a recorder.
Returns (module, recorded_delays). Intercepting the module instead
of monkeypatching after import means it works whether retry.py uses
``import time`` or ``from time import sleep``.
"""
recorded = []
fake = types.ModuleType("time")
fake.sleep = lambda d: recorded.append(d)
for name in dir(_real_time):
if not hasattr(fake, name):
setattr(fake, name, getattr(_real_time, name))
old = sys.modules.get("time")
sys.modules["time"] = fake
try:
spec = importlib.util.spec_from_file_location("retry", "retry.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
finally:
if old is not None:
sys.modules["time"] = old
else:
del sys.modules["time"]
return mod, recorded
class BackoffDelayTests(unittest.TestCase):
def test_grows_exponentially_until_the_cap(self):
mod, _ = load_retry()
delays = [mod.backoff_delay(n, 2.0, 5.0) for n in range(1, 6)]
self.assertEqual(delays, [2.0, 4.0, 5.0, 5.0, 5.0])
def test_cap_holds_even_when_base_delay_exceeds_it(self):
mod, _ = load_retry()
self.assertEqual(mod.backoff_delay(1, 7.0, 5.0), 5.0)
self.assertEqual(mod.backoff_delay(2, 7.0, 5.0), 5.0)
def test_never_exceeds_the_cap_for_many_attempts(self):
mod, _ = load_retry()
for n in range(1, 20):
self.assertLessEqual(mod.backoff_delay(n, 1.5, 3.0), 3.0)
class RetryCallTests(unittest.TestCase):
def test_retry_call_never_sleeps_longer_than_max_delay(self):
mod, recorded = load_retry()
calls = []
def flaky():
calls.append(1)
if len(calls) < 4:
raise ValueError("boom")
return "ok"
self.assertEqual(
mod.retry_call(flaky, attempts=4, base_delay=2.0, max_delay=5.0),
"ok",
)
self.assertEqual(len(calls), 4)
self.assertEqual(recorded, [2.0, 4.0, 5.0])
for d in recorded:
self.assertLessEqual(d, 5.0)
def test_raises_retry_exhausted_after_attempts(self):
mod, recorded = load_retry()
def always_fail():
raise RuntimeError("nope")
with self.assertRaises(mod.RetryExhausted):
mod.retry_call(always_fail, attempts=3, base_delay=0.5, max_delay=2.0)
self.assertEqual(len(recorded), 2)
def test_success_on_first_try_does_not_sleep(self):
mod, recorded = load_retry()
self.assertEqual(mod.retry_call(lambda: "ok", attempts=3), "ok")
self.assertEqual(recorded, [])
def test_rejects_invalid_arguments(self):
mod, _ = load_retry()
with self.assertRaises(ValueError):
mod.retry_call(lambda: 1, attempts=0)
with self.assertRaises(ValueError):
mod.retry_call(lambda: 1, base_delay=0.0)
with self.assertRaises(ValueError):
mod.retry_call(lambda: 1, max_delay=-1.0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,46 @@
"""Compare two semantic version strings.
`compare(a, b)` returns -1 if `a` sorts before `b`, 0 if they are equal,
and 1 if `a` sorts after `b`, following the SemVer 2.0.0 precedence rules.
"""
def compare(a, b):
"""Compare semantic version strings ``a`` and ``b``."""
def parse(v):
core, _, pre = v.partition("-")
nums = [int(p) for p in core.split(".")]
prerelease = pre.split(".") if pre else None
return nums, prerelease
(an, ap), (bn, bp) = parse(a), parse(b)
if an != bn:
return -1 if an < bn else 1
if ap is None and bp is None:
return 0
if ap is None:
return 1
if bp is None:
return -1
for x, y in zip(ap, bp):
x_num = x.isdigit()
y_num = y.isdigit()
if x_num and y_num:
if int(x) != int(y):
return -1 if int(x) < int(y) else 1
elif x_num:
# Numeric identifiers have lower precedence than alphanumeric.
return -1
elif y_num:
return 1
elif x != y:
return -1 if x < y else 1
# All shared identifiers are equal: more fields wins.
if len(ap) != len(bp):
return -1 if len(ap) < len(bp) else 1
return 0
Loaded 100 of 778 files, more files were not shown because too many files have changed in this diff. Show more