Compare commits

...
Author SHA1 Message Date
esengine 7b783209fd fix(studio): a thought the reader set to Open is not capped to the live six-line window
The cap keyed on data-live alone, so Open and Open-while-running looked the same while the model thought. SayCard now marks data-full when the thinking mode is open and the cap skips it.
2026-11-07 10:15:47 -08:00
esengine 03943d8a84 fix(studio): the pin button hides until its row is hovered, a pinned row keeps a marker, and the model menu's image badge no longer wraps
.ds-row .sesspin-action set display: grid in a later rule of equal specificity than the one hiding it, so every row showed the button at rest. A pinned row shows .ds-pin at rest when quick actions are on, as it already did when they are off. The badge in the model menu could shrink to one character per line beside a long name; it is now flex: none. Both are held by perf scripts and a rows.css rule test.
2026-11-07 09:19:30 -08:00
esengine 3db2fc12a6 fix(studio): the settings page index shares the title's column, and an open sub-page hides every row of the page it came from
On single-column pages the title and cards are centred in an 880px column while the page index was not, so it sat 95px to the left of them. A sub-page's hide rule lost to the later display rule of inline rows (equal specificity), leaving three empty rows above it. Both are held by perf/settings-page-column.mjs.
2026-11-07 08:25:49 -08:00
yhh 847d450594 docs(release): studio 2.58.0 notes name the update announcement and the new default 2026-11-07 07:11:37 -08:00
yhh 289033df01 feat(studio): a newer release is announced by default and from the sidebar: update_mode reads as notify until the user picks one, so a fresh install checks the catalog in the background, and the sidebar footer carries a button that names the version and opens the version page; a user who chose manual keeps it because the choice is now written to the file 2026-11-07 07:11:24 -08:00
yhh 2fff350d61 fix(jobs): the view test waits for the live job's first line before it reads the running list: the job goroutine had not written it yet on a loaded runner, so the tail came back empty in one run in about two thousand 2026-11-07 06:56:05 -08:00
yhh a5b6cbf977 ci: the push-only full suites run as shards: internal/frontend/serve and internal/assembly/boot are cut by test name, with the rest of the tree on its own runner, on Windows and under -race, so the wall time follows the largest shard instead of the largest package 2026-11-07 06:10:57 -08:00
yhh e115d0da4a ci: a newer push to studio cancels the CI and Studio runs still going for an older one, as a pull request already does; main-v2 keeps a result for every commit 2026-11-07 05:49:11 -08:00
yhh 0e43c6fbba fix(studio): ending a goal from its card removes the card at once: the status is only re-read while a turn runs, so a stopped goal that was cleared kept showing until the next turn; pause and resume read it again too 2026-11-07 05:11:08 -08:00
yhh de8dad66d6 docs(release): studio 2.58.0 release notes, with the board restyle grouped by area and the unreleased fragments merged into it 2026-11-07 05:07:56 -08:00
yhh 0b455f4c81 style(control): the skip-ordering comment keeps to the three lines the repo allows 2026-11-07 04:50:47 -08:00
yhh 01000d4646 fix(control): a skipped question is marked as a skip before its answer reaches the turn: the turn that got the answer first could run on or end before the gate knew why, so it finished as cancelled, was handed a dismissal, or did not requeue the guidance queued into it, which showed as two intermittent failures under the race detector 2026-11-07 04:49:51 -08:00
yhh 18015b1faf fix(studio): three inputs that write the host and had no action name now have one, which the census invariants step, never reached while the tests failed before it, refuses to pass without: the plugin paste field, the market cache retry and the onboarding start button 2026-11-07 04:23:12 -08:00
yhh 7586f9e785 fix(studio): two errors vitest counts as a failed run no longer escape: the image viewer's test supplies the pointer capture jsdom lacks, and the tool output's measuring frame skips an animation query the environment does not have 2026-11-07 03:58:10 -08:00
yhh abeb6b1b36 fix(studio): the workbench no longer renders again every two seconds for nothing: the terminal list poll skips the setter when it finds the list it already holds, since React runs the component once more for an update that hands back an equal value, which is what the poll test saw as a stray commit on the runner; the temporary probes are removed 2026-11-07 03:09:18 -08:00
yhh 1317859a0c test(studio): temporary probes in the workbench file reader, silent unless a test installs them, so the poll test's failure on the runner says whether the file entry was empty or evicted 2026-11-07 02:54:13 -08:00
yhh 8447cc10ac test(studio): the poll test waits through four quiet windows before it counts, and its failure says how many milliseconds after the baseline each commit landed 2026-11-07 02:34:23 -08:00
yhh 03cdec40a2 test(studio): the Markdown poll test says in its failure which poll committed, with how many file reads and listings had happened, so a runner-only difference shows its own cause 2026-11-07 02:23:59 -08:00
yhh f65f584c3f test(studio): the Markdown poll test waits until the panel has stopped committing on its own before it counts what a poll commits, since what opening a file leaves behind lands at a machine-dependent time 2026-11-07 02:00:17 -08:00
yhh bda618594f fix(studio): the browser guards run again from the first to the last script: five real layout regressions are fixed (chip mirror width, a leftover delete button's position, the context card's hover bridge, held approval buttons at 420px, the narrow file panel header), and the guards follow the icon rail being off by default, the settings tabs and deep pages, the phone's more tray and the other deliberate shape changes. Weakened on purpose and to be revisited: session-selected-row asks for 1.05 and 1.02 contrast instead of 1.2 and 1.12, nest-nodes' node ceiling is trivially met by the windowed flow, project-header no longer checks a more menu it does not have 2026-11-07 01:32:38 -08:00
yhh 98c340be47 test(studio): the backup connection tests find the passphrase field by the change action it now names, and the Markdown poll test waits out the 220ms tab-entry timer before it takes its baseline 2026-11-07 01:32:38 -08:00
yhh 87d20e14b3 fix(studio): on a desktop the settings search results fill the page column beside the category list instead of floating across it and the page behind, with the close button kept above them 2026-11-07 01:32:38 -08:00
yhh 4779d91c5b feat(studio): settings are found by what a person types: search ranks by relevance, takes several words and a sentence, and 23 of 43 everyday phrasings that found nothing now land in the first three, held by a findability test; each page opens with an index of what it holds, so settings folded into it have a visible way in 2026-11-07 01:24:25 -08:00
yhh 07d2d4bdd0 Show the cache hit rate after the cold start, and the output ratio
The session's first executor request pays for the prefix every later one
reuses, so the session-wide hit rate mixes "did the cache work once warm"
with "how much a cold start could reuse". usagereport already records the
cold start, but nothing a live session reads projected it.

The live figures come from the serve ledger, which restarts on /new,
/resume and /fork; usageTally does not, so its ColdStart can belong to an
earlier conversation in the same process. The broadcaster therefore takes
the cold start from the ledger's own population: the first request the
ledger files under the executor (pricing.LedgerSource, the rule Add
already used), split by the same CacheSplit the ledger applies, and reset
with it. SessionUsage carries it as coldStart.

- /status (TUI): "warm cache" row, ledger hit rate with the cold start
  taken out of hit, miss and unknown; "out/in" row, output ÷ input.
- Desktop session overview behind the cache meter: the same two figures,
  computed in state/overview beside cacheRate.
- wire-parity: ColdStartUsage mirrored in port/session.ts.
2026-11-07 09:06:37 +00:00
yhh 2e2566c84d test(studio): the Markdown poll test lets what opening the file left pending land before it takes its baseline, so a slower runner does not count it as a poll render 2026-11-07 00:53:34 -08:00
yhh 1b0fbd8bb1 fix(studio): after a batch selection focus returns to the project menu that started it, which no longer is a single list menu, and the session, workbench and rail tests follow the deliberate changes to the project menu, the scope dropdown, the palette ranking, the dirty-tab prompt and the line-numbered code blocks 2026-11-07 00:53:06 -08:00
yhh 6fd42831e2 fix(studio): opening the model panel reads the model list once, and the settings, composer and plan tests follow the deliberate structure of the settings nav folds, the account page behind its pill, the scope tag, the merged model and effort button and the send button staying beside stop 2026-11-07 00:42:50 -08:00
yhh e386c45525 fix(studio): the structural gates are held to the design as it is: SegChoice and PillButton name their action through a certified prop, the shared Menu row's callback is followed into its parent, the slider, strip scroll, dialogs and plan picker carry action names, keyframes, radii, orphan components, translations and settings rows are brought back in line, and the phone layout no longer hides the branch chip from the hover guard 2026-11-07 00:39:10 -08:00
yhh 0fac934b3f fix(ci): the Windows shell listing joins paths the way the OS it is computed for does, the save allocation check is skipped under the race detector that defeats regexp's pool, and the context-retrieval benchmark whose premise archived folds ended is removed with the surface helper only it used 2026-11-07 00:39:03 -08:00
esengine 48cad29d31 fix(studio): the storage move panel plans once the path stops changing and ignores answers for paths already typed past
Every keystroke sent a plan request that walks the whole root. The panel now waits 400 ms after the last change and drops an answer whose path is no longer the one in the box.
2026-11-06 23:50:40 -08:00
yhh 0f26eda9f4 chore: stop tracking the python bytecode caches that the last commit swept in from two untracked benchmark directories 2026-11-06 23:47:52 -08:00
yhh 14649962fc refactor(provider): remove the summary request flag and what only it served: the Request.Summary field and ErrSummaryToolsUnsupported, the adapters' no-tools branches, the broker's capability probe, and the test doubles and live test written for a summarizer call that no fold makes; the reported-usage effect test now detects a fold by its archive pointer and has a control arm 2026-11-06 23:47:31 -08:00
yhh 0f81082776 refactor(agent): remove the summarizer's bill and request identity, which nothing writes now that a fold is an archive: the spend accumulator, the unset usage and request id on the fold result, and the zero usage carried into telemetry and the receipt 2026-11-06 23:24:39 -08:00
yhh 31db766ff1 test(boot): through the real assembly a hand-asked compact folds again after a little more was said, the report names the fold an archive, and a declined one does not claim a summary 2026-11-06 23:22:14 -08:00
yhh 05c8743f03 fix(agent): a fold is described as the archive it is: the decline wording no longer says a summary is being paid for, /context and the TUI name an archived fold instead of calling it summarized or printing the raw mode, and the codes, modes and translations nothing produces any more are removed 2026-11-06 23:08:04 -08:00
yhh be92796684 test: the coordinator handoff and guardian compaction tests stop scripting a summarizer call, which compaction no longer makes since it archives to a file 2026-11-06 22:51:42 -08:00
yhh 6264845f53 fix(tool): a refusal's remedy names the setting tool by its capability id, tool:setting, now that the tool sits behind use_capability and a bare "setting" is not a name the model can see 2026-11-06 22:51:42 -08:00
esengine 20c2705623 chore: clear the unused agent symbols and the two lines App.tsx is over its ceiling
These block every push through lint and make check; none of them changes behaviour.
2026-11-06 22:49:20 -08:00
esengine 1923e50a34 fix(storage): a move plan proves the nearest existing parent writable instead of creating the target
Planning ran on every keystroke and its write probe called MkdirAll on the target, leaving a folder per typed prefix. The probe now runs in the nearest existing ancestor, free space is read from it, and Move creates the target once the user starts the move.
2026-11-06 22:46:13 -08:00
yhh 7c388628f2 fix(studio): the session-message form's recipient select fills the form instead of the 140px every select in a settings card gets, so its label no longer wraps over two lines 2026-11-06 22:41:24 -08:00
yhh 339f443ffb feat(boot): setting, present_files, compress and context_budget move behind use_capability and are indexed in its description, cutting the first-round tool definitions by another 4 KB 2026-11-06 22:34:44 -08:00
yhh c8afd3be60 feat(boot): the browser tools move behind use_capability, whose description now indexes the deferred tools by id with a short purpose, so a model that could see a browser but not web_fetch no longer opens a page to read a JSON endpoint 2026-11-06 21:59:04 -08:00
yhh 756c6159a6 fix(agent): a compact asked for by hand no longer answers "too little added" after a checkpoint; asking waives how much was added, and an unchanged context is still declined 2026-11-06 21:22:30 -08:00
yhh 26f72585ee fix(control): in yolo mode remember no longer asks for approval either, so neither memory tool prompts under the mode that skips every other approval 2026-11-06 21:13:36 -08:00
yhh 5b80976176 fix(control): in yolo mode forget no longer asks for approval; it archives a memory rather than destroying it, and remember still asks 2026-11-06 21:11:56 -08:00
yhh 044d2ee025 fix(studio): in a narrow conversation the composer's model pill shrinks the model name instead of letting the divider and reasoning level spill out of the pill and onto the send button 2026-11-06 21:11:55 -08:00
yhh 081f35e9dd fix(studio): the settings category strip on a phone can be scrolled with a mouse wheel, brings the chosen category into view, and fades at its edge so a cut-off tab reads as more to the right 2026-11-06 20:20:22 -08:00
yhh 6e56478321 fix(studio): on a phone the settings sheet no longer draws the desktop's nav-column divider and darker strip, which showed as a stray vertical line through the page 2026-11-06 20:18:23 -08:00
yhh 37ec853810 feat(studio): on a phone the settings sheet fills the screen as one column, with the categories as a strip above the page, so rows, titles and the skills list get the whole width instead of a sliver beside a nav column 2026-11-06 20:14:40 -08:00
yhh 73a1b23696 fix(studio): on a phone the composer's more-tools button can be seen (heavier dots inside a ring), and the model pill takes the room its text needs and shows the reasoning level instead of overlapping the button and leaving a dangling dot 2026-11-06 20:00:17 -08:00
yhh 8c71d355b0 feat(studio): the phone's reasoning control is the product's own depth line instead of a look modelled on another product's slider; a thin line in the brand colour with a current flowing along it, a node per level, and a thumb wearing the Reasonix mark. Also fixes the model pill's separator, which had been written as a NUL character 2026-11-06 19:55:42 -08:00
yhh 155ffaa49b feat(studio): the reasoning slider follows the finger continuously and eases onto a level, its stars drift, its light stays inside the card, and it carries a dot and a name for every level with a bloom behind the thumb; the sheet's search box is spaced from the list 2026-11-06 19:52:02 -08:00
yhh ef0ceacf78 feat(studio): the reasoning slider is lit from inside: stars that twinkle out of step, a glow that sweeps along the fill, and a glowing thumb; all of it holds still under reduced motion 2026-11-06 19:45:24 -08:00
yhh 552f48ab7a feat(studio): on a phone the reasoning levels are one gradient slider with the level named above it, chosen when it is let go, and the model rows in the sheet are compact 2026-11-06 19:42:41 -08:00
yhh e8b13d13dd feat(studio): on a phone the model menu is a sheet along the bottom edge instead of two squeezed columns; the model list scrolls by itself while the reasoning levels stay in view as one row of chips 2026-11-06 19:38:53 -08:00
yhh ae46c30d3e feat(studio): on a phone the composer's tool row is add, the model pill and send, with the other controls one tap behind a 'more tools' button; stop is a round pause button, and the pinned control order no longer applies on a phone 2026-11-06 19:27:53 -08:00
yhh 411e19d87e feat(studio): on a phone the title bar keeps the session list, the title with the machine under it and one menu, a lost connection says it is reconnecting, and an approval is two 44px buttons with the other choices behind 'more options' 2026-11-06 19:14:06 -08:00
yhh c155364b3d fix(studio): on a touch screen the select options are finger-sized and the context card's expand control and manage link are tall enough to press 2026-11-06 18:52:04 -08:00
yhh e89b435af9 feat(studio): every select opens the app's own list instead of the system one; one rule in the shared select styles covers all 35, behind a feature guard so engines without styleable selects keep the native list 2026-11-06 18:47:00 -08:00
yhh 0549af617b fix(studio): the context details card is tighter, and its expanded rows keep the card's side margin instead of sitting against the edge; a long tool name is cut with an ellipsis rather than pushing the figure out of the card 2026-11-06 18:39:35 -08:00
yhh ce51afd2dc fix(studio): a code block wraps long lines instead of scrolling sideways; each source line is its own row carrying its number, so the number stays with the first row of a wrapped line 2026-11-06 18:33:23 -08:00
yhh 2bfb7de07a feat(studio): the plan list opens from the height of its bar and leaves again before it is removed; the exit holds the list only while the stylesheet gives it an animation, so reduced motion and a missing animation close at once 2026-11-06 18:29:54 -08:00
yhh 8658f020af fix(studio): the global shortcut setting reads like its neighbours; the chord is shown in the notation people use, the three one-shot actions are pill buttons instead of a selector strip, recording is visible on the field, and the explanatory text wraps instead of being cut off 2026-11-06 18:22:53 -08:00
yhh 0a38f2e47a fix(studio): the open plan is as wide as its collapsed bar, and the transcript gives way to it instead of ending underneath 2026-11-06 18:17:52 -08:00
yhh 7c7e3ff99c perf(session): resuming a long session no longer repeats work; a replay observes each touched path once, event records are framed in bulk, and a load whose ledger agrees with its transcript is a verified baseline so switching away from an untouched session reads nothing back; resume of a 15 MB log goes from about 1.0 s to 0.42 s 2026-11-06 18:17:52 -08:00
yhh 868a14cadc perf(session): a save that finds its files as its own last save left them no longer replays the event log; the prefix is verified by digest in the pass that digests the transcript, cutting a 5000-message save from 137 ms to 50 ms 2026-11-06 17:25:44 -08:00
yhh 63da66261c perf(serve): package init no longer loads the OS mime table, taking Windows startup from 143 ms to 91 ms; page files already carry their own content types 2026-11-06 17:06:12 -08:00
yhh 76ed2566ed feat(context): a fold archives the whole span and keeps nothing: no first-turn pin, recent tail, kept user turns, user-words list, work index or summary; the pointer is one short message and keep, recent_keep, user_turn_keep_tokens and first_turn_pin_tokens are retired 2026-11-06 10:02:32 -08:00
yhh e808b48946 fix(studio): the dev server proxies /terminals to the kernel 2026-11-06 08:36:55 -08:00
yhh 817d78b399 fix(memory): the new save refusals carry wording in the desktop, and the remember freshness effect test states the mode it exercises 2026-11-06 08:28:08 -08:00
yhh 813af4958e feat(memory): the model asks before remembering — remember holds a suggestion the user accepts, edits or dismisses; memory.proactive is ask, auto or off, and YOLO saves directly; the open goal panel joins the composer 2026-11-06 08:17:42 -08:00
yhh b0786368ca feat(studio): the goal strip follows the Chat-Goal-Memory board with status dot, one usage line, stop reason and pause, resume and end; the memory row reads as the board does 2026-11-06 07:33:23 -08:00
yhh c457700233 feat(studio): first-run ends with the project step and a hand-over: drop or pick a folder, the card shrinks away, the composer rises and types its prompt 2026-11-06 07:26:01 -08:00
yhh c4375bcde9 fix(studio): the open-with menu names the editor as the board does (VS Code) and cuts a long name short instead of spilling out of the menu 2026-11-06 07:19:37 -08:00
yhh f5c4d463b9 feat(studio): first-run motion from the Onb-Motion board: the wordmark draws in, a 7-second session demo loops beside the welcome, the connect button becomes the R and then a green tick, the glow breathes 2026-11-06 07:18:11 -08:00
yhh 402b524fd7 fix(studio): the first-run welcome says what to do instead of a slogan 2026-11-06 07:13:08 -08:00
yhh a846cf7fc1 fix(studio): the compaction confirmation writes the context size with K like the board 2026-11-06 07:11:02 -08:00
yhh 87d9ed6150 feat(studio): the changes header shows the full branch name and the full count on hover, since it truncates them 2026-11-06 07:09:39 -08:00
yhh 9d5775c789 fix(studio): the changes page header stays one 40px row with the branch and the summary truncated and the buttons whole; the context details rows are 28px apart, not 34 2026-11-06 07:07:37 -08:00
yhh aadd62e2e5 fix(studio): the context details card follows the Chat-Tools board: K for thousands, the one-line hint, MCP counted as connected and tools, a plain Manage MCP, values in the interface face 2026-11-06 07:05:34 -08:00
yhh 77d875db8d fix(studio): the commit history in the explorer follows the WB-Frame board: 32px heading, 26px rows inset from the edge, hash, subject and a bare age 2026-11-06 07:00:05 -08:00
yhh c9fb8f7ee3 fix(studio): the refined-prompt card is drawn as the Compose board has it: inset in the composer, header, body and a footer on the page tone 2026-11-06 06:57:52 -08:00
yhh 1b3d1e0c8d fix(studio): the side question leaves the input the moment it is sent and returns to it only if the ask fails 2026-11-06 06:56:21 -08:00
yhh 6420f14fda fix(studio): a remote project row's new-session button is a plus as well 2026-11-06 06:55:04 -08:00
yhh f200f6108f fix(studio): markdown tables wrap their cells to the column instead of scrolling sideways 2026-11-06 06:54:15 -08:00
yhh 572e0d54b3 fix(studio): a project row's new-session button is the plus the Sess-Rows board draws, not a chat bubble 2026-11-06 06:48:31 -08:00
yhh 0a43322267 feat(studio): opening a project whose folder is gone asks, in the list, whether to remove it from the list, instead of reporting the system's sentence 2026-11-06 06:47:20 -08:00
yhh 1ff0525a34 fix(serve): opening a project whose folder is gone is refused with workspace.folder_missing and the path, not the system's stat sentence 2026-11-06 06:44:19 -08:00
yhh ec45959a6e fix(studio): on macOS the brand in the sidebar head starts to the right of the window controls instead of under them, as the Mac-Titlebar board draws it 2026-11-06 06:41:20 -08:00
yhh 253733c1f9 fix(studio): the effort column of the model panel no longer reaches 8px past the panel, which clipped its heading against the top edge 2026-11-06 06:36:23 -08:00
yhh 32f597d16e feat(studio): the dividers are a line with no handle, and a collapsed sidebar is reopened by the icon in the top bar and Ctrl+B, as the Rail-Collapse board draws it
The grip on each divider (a dotted handle, an arrow tab when shut) is gone; the
divider still drags, resets on double-click and sizes from the keyboard, and a
shut column is a thin edge that can be dragged back out. The sidebar toggle is
the panel icon at the top left whenever the sidebar is collapsed, and its
shortcut is Ctrl+B as the board says, replacing Ctrl+backslash.
2026-11-06 06:18:10 -08:00
yhh c78b996d13 fix(studio): the idle run-state row no longer leaves a blank band above a note strip, so the strip sits at the top of the composer as the Compose-Notes board draws it 2026-11-06 05:19:16 -08:00
yhh 933ee31d6c feat(studio): the notes above the composer are one line at a time, as the Compose-Notes board draws them; a remote project's terminal says which machine it is on
The composer showed the connect strip, the trust question and the sandbox note
together, each with a paragraph, and a short stack clipped them over the input.
Now one strip is drawn, by priority (cannot send, trust, no sandbox): a single
line, the explanation in the hover text, calm grey for reminders and warm only
for "cannot send".

A terminal opened from a remote pane runs on that machine through the existing
forwarding, and its header carries the host. A test holds that the host's
requests are forwarded and a paired device's never reach the far kernel.
2026-11-06 05:06:01 -08:00
yhh 83bdeb81f0 feat(studio): the workbench gets a terminal of your own, as the Terminal board draws it
A fourth kind of tab opens a real shell (ConPTY on Windows, a pty elsewhere) in
the project folder, outside the model's permissions and sandbox and with the
host's provider keys left out of its environment. The kernel keeps each shell's
output, so a tab left or a window reloaded comes back as it was; a tab is named
for the command submitted while the shell is busy and carries a blue dot, and
closing a busy one asks first.

Also: the composer's note stack no longer clips to a few pixels when it holds
two notes, which left the second one overlapping the input.
2026-11-06 04:44:41 -08:00
yhh 3cf430d823 feat(studio): restoring a backup asks for the passphrase and reads the differences in a dialog, and says what happened in one strip per outcome, as the recovery board draws it 2026-11-06 03:34:59 -08:00
yhh efcd3bbf51 feat(studio): notices carry an info, warning or error glyph instead of a dot, and a pending permission request puts Refuse at the left, as the cards board draws them 2026-11-06 03:06:42 -08:00
yhh a3303025fe feat(studio): a turn closes with a tick and Done · duration at the right edge, as the reply board draws it; start time moves to the hover 2026-11-06 02:57:38 -08:00
yhh 6ebff84af2 feat(studio): the extension page gets a project pill beside Add, so the project is switched in place; the settings row stays as the same control 2026-11-06 02:50:06 -08:00
yhh 5e00091026 feat(studio): the extension project scope moves into settings as an Extension scope row on the Permissions page; the extension page only says so when it is managing another project, as the new Set-scope board draws it 2026-11-06 02:35:44 -08:00
yhh 4c6ba31a1c test(studio): the automation card test waits for the scheduled-runs row, which appears once its board is read 2026-11-06 02:28:38 -08:00
yhh 5868b02bdf feat(studio): hooks and scheduled runs move from the extensions page into an Automation card on the Agent behaviour page, as the new Set-automation board draws it 2026-11-06 02:28:06 -08:00
yhh ab6052b8a2 feat(studio): the extensions page drops the separate Runtime card for the board's one-line note on when changes take effect, keeping the rescan button beside it 2026-11-06 02:12:29 -08:00
yhh d437997362 feat(studio): a reply whose layout fails shows its source with an as-written tag, and a tool card that cannot be drawn falls back to a simplified line, as the recovery board draws them 2026-11-06 02:06:21 -08:00
yhh e4c97fdfab feat(studio): visual effects is one switch and the appearance page gets its advanced-appearance row, as the look board draws them 2026-11-06 02:04:33 -08:00
yhh c8eb8693fa feat(studio): native session import is a dialog with a dashed drop area, theme and session import results are the recovery board's green and warning strips 2026-11-06 02:03:09 -08:00
yhh 851f5fe19f feat(studio): the export-to-file categories are pill checkboxes with two passphrase columns and a warning-tone shortfall, as the recovery board draws them 2026-11-06 02:01:16 -08:00
yhh 7c26f0d5ec fix(studio): the notice and config sub-lines get their own class names, which the monospace guard reads as prose 2026-11-06 01:59:36 -08:00
yhh 0e04e0d753 feat(studio): the unparseable-config banner is the recovery board's card at the top of the settings page, with the offending line, the repair and a backed-up repair action 2026-11-06 01:59:03 -08:00
yhh f05b2392c3 feat(studio): update, size-ask and stall notices use the board's card; the unsaved-preset dialog lists what changed; settings-did-not-open is a centred placeholder; notices past three fade out 2026-11-06 01:55:41 -08:00
yhh e41bddb590 feat(studio): notices are the board's one strip — 380px cards with an icon and ghost-pill actions, stacked above the composer, newest three kept; the queue style guard follows the new rules 2026-11-06 01:50:17 -08:00
yhh e7cc0d9841 fix(studio): the extensions page names its group External services, as the installed board does 2026-11-06 01:39:50 -08:00
yhh 61d2f00f17 feat(studio): the appearance size row offers small, standard and large in place, as the look board draws it 2026-11-06 01:36:56 -08:00
yhh 7c4f42b1c0 fix(studio): the context card says where the window folds on its own, and the compaction confirm uses the board's wording 2026-11-06 01:25:54 -08:00
yhh cc79075291 feat(studio): the queue strip sits inside the composer card between the run row and the input, as the composer board draws it 2026-11-06 01:20:41 -08:00
yhh 1fbc0f52dd test(studio): a waiting steer stands at the end of the transcript, also while a sentence streams 2026-11-06 01:15:51 -08:00
yhh 195e048b55 feat(studio): steer and queued lines follow the composer board — a waiting steer is a dashed bubble at the end of the conversation, a queued line is one pill row with make-steer, send-now and withdraw 2026-11-06 01:09:14 -08:00
yhh b1106b98f3 fix(studio): settings number fields get a visible box, and the slash menu lays its rows out on one line as the composer board does 2026-11-05 22:50:10 -08:00
yhh 53ca70d347 fix(studio): code type is set in IBM Plex Mono as the Type board declares, not JetBrains Mono 2026-11-05 21:01:44 -08:00
yhh e000d91b13 fix(studio): the usage page's export button is drawn as a quiet pill instead of a bare browser button 2026-11-05 20:58:47 -08:00
esengine dc768371ab chore(agent): drop the snipping helpers nothing calls since compaction archives to a file
The unused linter reports eight symbols in prune.go that the archive-to-file change left without a caller; the strategy type and its hint mapping, which are still used, stay.
2026-11-05 20:48:17 -08:00
esengine e9b0b23224 docs(release-notes): file-changes setting wording 2026-11-05 20:44:46 -08:00
esengine 7f4d00ca95 fix(desktop): the file-changes setting says the count sits at the top of the input box
The setting promised it above the input box while the chip draws inside the composer's top edge, as the design board shows. Both lines of the setting now say top.
2026-11-05 20:44:46 -08:00
yhh 470cc01b0a fix(studio): the usage page lays its charts out as the board does and its table header follows the board's type
With two currencies the third chart took a row of its own and left the right half of the page empty; the spend charts now stack in one column beside the usage chart. The daily table's header is 11px medium in the interface face, not 13px bold mono, and its rows take 9px padding.
2026-11-05 20:29:34 -08:00
yhh ff1ba768f0 fix(studio): the session overview and context details cards follow the board's content, not only its sizes
Overview: four figures (requests with the main/sub split, cost, tokens, cache hit), each source as a bar with its cost, and a footer with the work time and a link to the context details; the context window is no longer drawn twice. Context details: the compact action, the MCP count and the manage link share one footer row instead of a server list, and a row with a breakdown shows its figure and a chevron, not a bordered pill.
2026-11-05 19:53:59 -08:00
yhh de226cae50 fix(studio): the title bar behind the settings window is veiled too
The veil starts below the title bar, so with the enlarge mode gone its text showed sharp above the blurred app. A strip beside the veil, not inside it because a blur only reaches what is within its own element, now blurs that band and lets clicks through to the title bar's buttons.
2026-11-05 19:45:30 -08:00
yhh af59635bf6 fix(studio): settings is drawn as a gear, not a sun
The sidebar's settings button and the common settings page wore a circle with eight rays, which is the sun the appearance page and the light-or-dark control already are, and at 16px the two could not be told apart. Both now use one toothed gear; the appearance page keeps its sun.
2026-11-05 19:27:25 -08:00
yhh 281029d1a8 fix(studio): the settings window has a largest size and stays centered instead of filling a big screen
1360 by 900 at most, over the dimmed app; a smaller window still gets it filled. On a 2000px window the content was either 700px columns or a block with 180px of empty margin each side. Follows the revised board Set-Wide.
2026-11-05 19:20:51 -08:00
yhh 3bf7491840 refactor(studio): the settings window loses its enlarge mode and keeps one close button
The sheet already fills the window, so enlarging only took it over the title bar and added three window buttons beside its own two. The control, the stored preference, the window buttons it summoned and the drag-region rules that went with it are removed; the board draws no buttons in that corner besides closing.
2026-11-05 19:13:20 -08:00
yhh 4d29b0bf74 fix(studio): the settings block is wider so a wide window leaves less empty margin
Single-column pages cap at 880px and two-column pages at 1400px, up from 760 and 1160. Follows the boards Set-Wide and Set-Full.
2026-11-05 19:06:45 -08:00
yhh 1d98de9d4b feat(studio): settings rows say only what differs
A block that applies on this machine at once says nothing; one that needs a restart, belongs to the workspace and so on gets a tag beside its name, and the page title carries one line saying what the usual is. Descriptions stay on one line with the whole text on hover, rows take 6px padding, and a row with no description is a single line. Follows the board Set-Full.
2026-11-05 19:05:28 -08:00
yhh 7ec53ac034 fix(studio): in two-column settings the page title sits with the block and a row with a control and a long note no longer squeezes its label
The right column of a settings row was sized to the widest line of the note under its control, so at 572px a column took 501 and the label shrank to one character. It is now capped at 320px and the note wraps. The page title takes the block's width and centering.
2026-11-05 18:48:28 -08:00
yhh f78be8c399 fix(studio): settings content is centered and 常用 goes two-column when wide; the changes page folds its per-file note into a tag past eight files
Settings: the card column is centered, 760px for single-column pages and 1160px for two-column ones, with 常用 added to the two-column pages; below 860px it stays one column. Changes: past eight files a new or untracked file shows a tag in its own header instead of a folded row under it, the top line splits modified from untracked, and the header weights, the 14px checkbox and the note's weight follow the board. Follows the new boards Set-Wide and WB-Changes-Many.
2026-11-05 18:44:50 -08:00
yhh 97a5ba687c docs(studio): the picker paragraph in TOKENS.md stays within the prose budget 2026-11-05 18:26:01 -08:00
yhh cb6fe7ef9c fix(studio): pickers follow one panel spec; in the model panel only the rows scroll
The model panel pins its title, filter and footer and scrolls just the list inside its own column, with the effort column fixed beside it and both capped at 440px. The agent preset menu is 340px with 12px descriptions and the workspace dialog 340px. TOKENS.md now states the picker panel rules the board Spec-Pickers draws instead of the older 12px-corner, 11px-description line. Menu items can be marked foot to stay under the scrolling rows.
2026-11-05 18:25:26 -08:00
yhh 5a0cf3f6ea build(studio): make studio skips the frontend and kernel steps when nothing they read is newer than what they made
STUDIO_REBUILD=1 forces both. The comparison is a small Node script because the launcher runs under a Git Bash that may have no GNU find.
2026-11-05 18:14:07 -08:00
yhh bd62832a62 fix(studio): the model and effort panel is the board's size
600px instead of 700, capped at 440px tall; model rows are one 34px line with the provider note and badges beside the name; effort rows lose the strength bars and the third line; the column fits its content and stays in view, and its footer is the single line the board draws.
2026-11-05 18:08:46 -08:00
yhh 9e95d0837e fix(studio): the work-mode menu follows the board and no longer lists Ask
300px panel, a bold title with the scope on the right, two-line rows with a 12px description, the current row in a grey wash with a blue tick. The unimplemented Ask row and its two strings are gone; it comes back when the mode exists.
2026-11-05 17:59:15 -08:00
yhh f0aca7b212 fix(studio): the execution-process group is a quiet line, not a card
No border or box: a 26px line in the transcript's own type, the running label shimmers as text (the same 1.6s light as thinking) and nothing else moves while it is folded, open it is an indented list on a hair rule, a failure is a small pill, and a finished group folds to one faint line of count and time. Follows the Think-Exec light variant.
2026-11-05 17:47:32 -08:00
yhh b3b5ef409f fix(studio): the configure-workspace dialog follows the board
360px panel, a small two-segment switch with the creation note under it, a source-branch label, 32px search and branch rows in the mono face with a pill for the current or occupied branch, and the one-line note at the foot. The per-row chevrons, dividers and the switch's icons and ticks are gone; the folder name and path stay as quiet lines because the board does not draw the current-folder page.
2026-11-05 17:44:52 -08:00
yhh b7da613abe fix(studio): the not-connected note is drawn as the board has it, and the changed-files pill no longer sits on a note
The note is a borderless warn wash inside the input box with a text link, not a bordered strip with a filled button. The pill's pull-up margin assumed nothing above it in the box; with a note present it now takes its own line.
2026-11-05 17:33:54 -08:00
yhh 4d0794b8f0 fix(studio): the overlays aligned to the board draw its 500 as --w-reg, not --w-med
The board sets body text at 500; the default weight ladder is 500 / 600 / 700, so 500 is --w-reg. Rules written as --w-med drew every line one step heavier than the board, and on Windows 600 falls on the bold face. Side panel answers also take the board's 1.7 line height.
2026-11-05 17:29:24 -08:00
yhh ca80fec2fc fix(studio): a hand-written provider at a declared address takes that preset's vendor mark; the setup cards show vendor marks
The mark for a model came only from the preset a provider was installed from, so an entry pointing at api.deepseek.com by hand drew a letter tile. The address is now read through the curated preset serving it, as vision models already are. Setup service cards draw the mark their vendor declares.
2026-11-05 17:17:45 -08:00
yhh 1b8b56aa65 feat(studio): compaction archives earlier turns to a file instead of summarizing; a skipped ask ends the turn as finished; chat overlays follow the design board
Compaction moves the folded span into <session>.outputs/fold-<hash>.md and leaves a pointer the model reads back with read_file. The summarizer path, its retries, bounds and tests are removed.

Answering an ask with no selection ends the turn normally instead of reporting a failure.

Find bar, overview and context cards, compact dialog, image preview, root-missing banner, toasts, side panel and prompt refine are aligned to the Chat-Tools board; the agent preset picker follows the Agent-Picker board.
2026-11-05 16:25:25 -08:00
esengine 1cdbb9303f test(tui): the wait for the provider after a saved key is the hang-catching budget
A fixed ten seconds reported a hang on a loaded Windows runner where the first-run test had already spent forty seconds. testenv.Budget exists for waits that only catch a hang.
2026-11-05 08:19:41 -08:00
esengine 8beea984d0 test(config): the churn in the state-root walk test finishes every removal it starts
A removal that lost a race with the walker was dropped, so the directory stayed behind and, because names are never reused, a later walk could not tell it from an unreadable one and failed after the five-second re-stat window. The churn now retries the removal until it succeeds, which is what the never-reused-name invariant assumes.
2026-11-05 08:19:27 -08:00
esengine 8a4c879184 docs(release-notes): installer and the shared computer-use driver 2026-11-05 07:15:21 -08:00
esengine f1bbea3885 fix(installer): a path-less computer-use driver no longer blocks an install or update
The running-copy check matched processes whose path it could not read by name, and the name list included cua-driver.exe, which other computer-use apps ship too. An elevated or orphaned driver from another app therefore read as Studio still running, and the retry loop could never succeed. Only the app's own executable and its host are matched by name now.
2026-11-05 07:15:12 -08:00
esengine 863575ebf7 docs(release-notes): point the restyle lines at the commits' current hashes 2026-11-05 10:50:35 +00:00
esengine 6a33920ae7 docs(release-notes): settings switches 2026-11-05 10:45:30 +00:00
esengine 965b9c1af8 feat(studio): plan progress, office preview and new-answer divider are switches as the board draws them; the language reads Simplified Chinese; execution rows lose the fold arrow 2026-11-05 10:45:25 +00:00
esengine 802f368ce8 docs(release-notes): starter chips and changed settings 2026-11-05 10:41:05 +00:00
esengine c93eabbe4c feat(studio): starter chips in the empty conversation and a changed-settings card on the common page 2026-11-05 10:41:00 +00:00
esengine 3fbc2dc105 docs(release-notes): live pulses 2026-11-05 10:21:41 +00:00
esengine b2b1b23027 fix(studio): running pulses take the live duration and the streaming caret blinks as the board draws it 2026-11-05 10:21:36 +00:00
esengine b0123ea2f9 docs(release-notes): keyframe convergence 2026-11-05 10:20:06 +00:00
esengine 53cd9a0ac3 refactor(studio): keyframes converge to the six spec 07 shared ones plus the ones a board draws, registered in a test 2026-11-05 10:20:01 +00:00
esengine 0e404acc2e docs(release-notes): onboarding motion 2026-11-05 10:01:17 +00:00
esengine 15078fbdb6 feat(studio): onboarding motion: 40px step slide, tile entrance, staggered choices, error shake and fade 2026-11-05 10:01:12 +00:00
esengine 2cde9eb7ce docs(release-notes): motion pass 2026-11-05 09:58:57 +00:00
esengine cf4ed6d7c6 fix(studio): motion follows spec 07: entrance animations play again, spec durations and one global reduced-motion rule 2026-11-05 09:58:51 +00:00
esengine 280e48f4dd docs(release-notes): composer model name 2026-11-05 09:52:53 +00:00
esengine e239a07459 fix(studio): the composer keeps the full model name in a narrow conversation column 2026-11-05 09:52:48 +00:00
esengine 77aaa519eb docs(release-notes): execution group frame 2026-11-05 09:49:21 +00:00
esengine 25f26b071d fix(studio): execution group keeps its hairline frame as the Think-Exec board draws it, without the shell tag 2026-11-05 09:49:15 +00:00
esengine 448ff4d19c docs(release-notes): conversation and workbench type pass 2026-11-05 09:33:00 +00:00
esengine f4f342cecf fix(studio): conversation cards, status popovers and workbench follow the boards' font size and weight; reading text is the regular weight 2026-11-05 09:32:54 +00:00
esengine dff8c7dc16 fix(studio): card install button, contribution rows and market filter follow the boards' size and weight 2026-11-05 09:13:23 +00:00
esengine df02bd5938 docs(release-notes): font size and weight pass 2026-11-05 09:08:53 +00:00
esengine 22aeea5cd6 fix(studio): match the boards' font size and weight on feedback, usage, onboarding, extensions, memory, remote and cards; package detail with the install step in the head; icon-only settings controls 2026-11-05 09:08:47 +00:00
esengine 9919c5c4d1 docs(release-notes): my published rows 2026-11-05 08:51:20 +00:00
esengine 52ce4c5dd1 fix(studio): my published packages list as rows, and the market group stays flat on hover and focus 2026-11-05 08:51:14 +00:00
esengine 81f368fb94 docs(release-notes): extensions market flat 2026-11-05 08:46:04 +00:00
esengine 870a5ef3cd feat(studio): extensions market and list sit flat on the page, without a frame per group 2026-11-05 08:45:58 +00:00
esengine b5833bff78 docs(release-notes): extensions list sections 2026-11-05 08:36:59 +00:00
esengine e512b30cf1 feat(studio): extensions list as flat sections beside an unframed detail 2026-11-05 08:36:53 +00:00
esengine 9c25f0e2e6 docs(release-notes): phone access popover 2026-11-05 08:30:06 +00:00
esengine ec63de72e5 feat(studio): phone access shows the QR beside its expiry and actions 2026-11-05 08:30:00 +00:00
esengine 98d83116de docs(release-notes): remote machine cards 2026-11-05 08:24:11 +00:00
esengine b9c03d4a95 feat(studio): remote machines as cards with one next step and the on/off in the menu 2026-11-05 08:23:54 +00:00
esengine c8c4162818 docs(release-notes): name the commit on the receipt and card lines 2026-11-05 08:16:29 +00:00
esengine 5e1c9b652f docs(release-notes): receipt removal and light remember/guardian cards 2026-11-05 08:16:07 +00:00
esengine 8472468039 refactor(studio): remove the delivery-receipt chain and the completion-standard row; light remember and guardian cards 2026-11-05 08:16:07 +00:00
esengine b666c13dd4 docs(release-notes): lighter compaction, execution and delivery cards 2026-11-05 07:31:37 +00:00
esengine 12bad68c6a feat(desktop): the compaction card, the execution group and delivered files lose their frames, as Chat-Cards-Light draws
The compaction card is titled by what it did (earlier content folded into a
digest, auto or manual) with its reason and sizes in one line and no bar or
frame of its own; the execution group has no outer frame, its failure count is
red text, and a failed step's reason sits behind a 2px red rail instead of in a
tinted box; delivered files are one list with hairlines, their open action a
text link. Frames stay on the cards that ask for something.
2026-11-05 07:31:37 +00:00
esengine c4927af867 docs(release-notes): fold earlier content after compaction 2026-11-05 07:24:21 +00:00
esengine 7b43b14559 feat(desktop): the transcript folds what an earlier compaction replaced, as Chat-Compaction draws it
After the second compaction the content before the second-latest compaction
folds behind one divider line at the top of the conversation ("N earlier
messages folded into a digest · show the folded content"); the stretch the
latest compaction closed stays on screen, so a fresh compaction never leaves the
page empty, and each further compaction folds one more stretch into the same
line. The fold is display only, always starts folded, and opens in place.
2026-11-05 07:24:21 +00:00
esengine 58c0b7b4a8 fix(desktop): register the palette's scope chip action
The scope chips added to the command palette carry data-action=palette.scope,
which the action catalogue did not list.
2026-11-05 06:46:30 +00:00
esengine ae2ade4485 docs(release-notes): usage page layout per board 2026-11-05 06:38:24 +00:00
esengine 9549614364 feat(desktop): the usage page lays out as Usage-Page does
The paid figure is the first of four tiles instead of a hero above them, with
its estimate and coverage caveats under it; daily tokens and daily spend sit
side by side, spend drawn as bars rather than a line; the duplicate block
heading above the page goes. The kernel still reports only a total for output,
so there is no output tile.
2026-11-05 06:38:24 +00:00
esengine b2abc6b103 docs(release-notes): command palette per board 2026-11-05 06:16:12 +00:00
esengine e28583407c feat(desktop): the command palette draws scope chips, counted groups and session status, as Cmd-Palette does
The palette gets the board's scope chips under the input (actions, sessions,
settings; each toggles its scope), a count on each group once something is
typed, and a coloured dot with the word on a session that is running or waits
on the person, the rest carrying their age. Notes that say something about a
row sit beside its name; a shortcut keeps the right edge. The foot puts the keys
on the left and the scope symbols on the right. Files are not a scope: nothing
in the port lists a workspace's files.
2026-11-05 06:16:12 +00:00
esengine 20880b24b1 docs(release-notes): onboarding welcome step per board 2026-11-05 06:06:23 +00:00
esengine a54fd45859 fix(desktop): the onboarding welcome step is centred with the wordmark, as the board draws it
Onb-Flow's welcome centres a large wordmark, the headline, one line of body,
the primary button and the note; it was a left-aligned card with the skip
beside the button. The skip stays, now under the button. The board's
import-from-backup link is not drawn: onboarding has no import flow to call.
2026-11-05 06:06:23 +00:00
esengine 06c8ca12e7 docs(release-notes): appearance, display and operation settings pages per board 2026-11-05 05:13:56 +00:00
esengine 98e3406998 feat(desktop): Appearance, Conversation display and Operation & performance are three pages of their own
They were one component hiding the other two's blocks with a stylesheet. Each
page is now composed as its board draws it: Appearance as theme, text and
window cards (wallpaper, typeface, size and window behind pills; light/dark,
effects, weight and contrast on the row), Conversation display as process,
files and previews, and notices, and Operation & performance as shortcuts, tabs
and navigation, while running, and performance. A single-control block draws as
one row through Group's inline form. The advanced-appearance disclosure goes:
its weight, contrast and effects are rows now. Rows carry the id landing and
search look for.
2026-11-05 05:13:56 +00:00
esengine 33c66feebd docs(release-notes): connection and account settings pages per board 2026-11-05 05:02:44 +00:00
esengine bf6ff1be7f feat(desktop): the Connection and Account pages are the board's cards of single rows
Set-connect draws network, and other machines (remote, phone access) as one
row each behind a pill; Set-about draws account, version and diagnostics
(version, logs, trace export) and a short About note, with community and
sponsor behind pills. Landing on a setting that is its own page now targets the
page itself, and the section counts leave out blocks drawn inside another page.
2026-11-05 05:02:44 +00:00
esengine af75bc400f docs(release-notes): data settings page per board 2026-11-05 04:55:48 +00:00
esengine 6a733bb779 feat(desktop): the Data settings page is the board's three cards of single rows
Set-data draws memory and usage, session storage and backup with one short
row per setting: memory (with its count), usage, upload volume, storage and the
two backups sit behind a pill, while currency, auto-archive, session location
and 1.x sessions act on the row. The board's upload switch is not drawn: the
kernel has no upload setting, only a report of request sizes, so that row opens
the report. Opening settings at 'memory' still lands on the memory page.
2026-11-05 04:55:48 +00:00
esengine d29b99824e docs(release-notes): agent behaviour settings page per board 2026-11-05 04:44:40 +00:00
esengine 3d0fae17aa feat(desktop): the Agent behaviour page is the board's three cards of single rows
Set-agent draws persona and presets, plans and questions, and collaboration as
one short row per setting. Plan approval, narration and sub-agent write scope
become segmented rows, the question wait and the reference size keep their
value beside the row, and sessions-between-sessions sits behind a pill. What
the old long descriptions said about what a switch releases or costs stays as
the row's hover text. The plan-mode switch goes (the start mode owns it), and
the file-changes, fold-work, receipt and level-notice switches move to the
conversation-display page where the board puts them.
2026-11-05 04:44:39 +00:00
esengine d7d32602c1 docs(release-notes): permissions settings page per board 2026-11-05 04:36:53 +00:00
esengine c4e07cbe0c feat(desktop): the permissions settings page is the board's three cards of short rows
Set-safety draws approval (tool approval, rules, outside text, tools), sandbox
and execution, and web as one row each: the controls that are a value stay on
the row, the ones that are an editor sit behind a pill with their count. The
outside-text switch keeps what it covers and what it does not as the row's hover
text, so the board's one line does not drop it. Message keys the old blocks
alone used are removed.
2026-11-05 04:36:53 +00:00
esengine 2221374763 docs(release-notes): models settings page per board 2026-11-05 04:29:14 +00:00
esengine 0b8c08fd53 feat(desktop): the Models settings page is the board's two cards of four rows
Set-models draws services (model services, health check) and uses (by-use
models, context maintenance) as short rows, each with its scope and when it
takes effect. A block that names an action now draws as a setting row with a
pill that opens the same page; the long content moves behind the pill, so
providers, by-use models and the compaction gauge are pages of their own.
Also: Ctrl+Enter reads as written in the board, and the selected settings
nav item keeps the regular weight the board draws.
2026-11-05 04:29:14 +00:00
esengine 637d1aa4c8 docs(release-notes): full-width wording and tool failure line 2026-11-05 04:13:50 +00:00
esengine 4e09005d01 fix(desktop): the full-width option has its own words again, and a refused tool's failure line says the host's reason
Appearance borrowed feedback.full_width for its full-width option, so removing
that key with the old feedback form left the option reading as a raw key. The
option gets look.full_width in its own namespace.

In the activity group a failed call's line preferred the last line of its
output over the host's error. For a command that tail is its stderr and says
more than "exit status n"; for any other tool the output is not about the
failure, so the host's error wins there. Tests that still expected the drawings
replaced by the boards (exit labels beside the exit summary, the old missing-file
sentence, the ext settings target) now hold what the boards draw.
2026-11-05 04:13:50 +00:00
esengine 945a3de38f docs(release-notes): sidebar rows per the session-row board 2026-11-05 04:09:30 +00:00
esengine 112ea17e8d feat(desktop): sidebar rows follow the session-row board: no project counts, remote state inline, unread dot named
Sess-Rows draws project rows without a session count, folded or open, and a
remote project with its chevron and its state word (connecting, offline) after
the host. The count, its component and its two messages go; what is left to
load is already said by the show-more row beneath the sessions. The connection
line now carries only the detail the word does not (the step, the error).

An unread row shows its time beside the left dot, as the board draws it, so the
dot is the only unread mark and now carries the accessible name. The effort
reading in the composer gets the board's icon. The perf mock gives its sessions
times so the harness shows what the board shows.
2026-11-05 04:09:30 +00:00
esengine 69f88a7dbc docs(release-notes): settings frame 2026-11-05 03:26:34 +00:00
esengine febf34475a fix(desktop): Settings fills the window as the board draws it
Settings opens as a sheet filling the window inside a 24px margin, the
board's frame, rather than a smaller box centred in it; the content
column keeps its 760px measure. The Common page's header adds the board's
line that it is a shortcut and each setting also lives in its category.
2026-11-05 03:26:33 +00:00
esengine ad4098781c docs(release-notes): switch spec 2026-11-05 03:21:32 +00:00
esengine eb386d72ac feat(desktop): switches and button weight as the actions spec draws them
The shared switch is the spec's: a 34 by 20 outlined track with a 12px
knob, grey when off, and when on an accent outline over the accent wash
with the knob in the accent. Buttons from the shared kit set their label
in the medium weight the spec gives every button size.
2026-11-05 03:21:32 +00:00
esengine d8948232ef fix(desktop): keep the usage chart's hues in the design sheet
The cache hit and miss hues moved from the legacy stylesheet, which they
had pushed past its size budget, into the design sheet beside the chart
rules that use them.
2026-11-05 03:19:50 +00:00
esengine 14117d1d33 docs(release-notes): usage page 2026-11-05 03:19:12 +00:00
esengine 2228d88625 feat(desktop): usage as a page of its own, input split by cache, CSV export
The rail, the palette and the meter open Usage as its own page, titled
Usage, holding only the usage blocks: the display currency, usage and
cost, and upload volume. A section drawn as a page of its own now keeps
only the blocks the catalogue files under it, so Extensions likewise
shows no blocks from neighbouring sections.

The daily chart draws the input side as stacked bars, cache hits and
misses in a validated pair of hues with a legend, and its tooltip adds
the total the provider reported, which shares no axis with the split.
The tiles read input with its cache rate, the provider-reported total,
requests with turns, and active days with requests a day; output is not
drawn, since the kernel does not report it apart from the total. Export
CSV writes the range's days, one money column per currency.
2026-11-05 03:19:12 +00:00
esengine c1b11291cf docs(release-notes): approval bar 2026-11-05 03:11:52 +00:00
esengine 8e89cefdd0 feat(desktop): a package's approval drawn as a bar beside its votes
The vote row in a market package's detail draws the approval rate as a
bar, green for the share of positive votes, before the words that state
it. A package with no votes draws no bar.
2026-11-05 03:11:52 +00:00
esengine 3283baaa6d docs(release-notes): market cards 2026-11-05 03:10:53 +00:00
esengine 8473abce76 feat(desktop): the market as a grid of cards with install on the card
Discover lays packages out as cards: a kind icon, the name with a check
when an admin marked it trusted, the publisher and version, two lines of
summary, then the kind, whether it is pinned, installs and approval along
the foot. An installed package says so in the corner; a pinned one not
yet installed offers Install there, which opens the package straight onto
what it would install. A package nobody pinned has no shortcut: trusting
its publisher stays a choice made on its page.
2026-11-05 03:10:52 +00:00
esengine d13c2ce4c2 docs(release-notes): extensions page 2026-11-05 02:54:26 +00:00
esengine 7abb8c1429 feat(desktop): extensions as a page of their own, list beside detail
The rail, the command palette, the meter and the runtime notice now open
Extensions as its own page rather than as a section of Settings: titled
Extensions, without the settings navigation or search, and with the
Installed tab laid out as the list beside a detail pane. Choosing a
package, service or skill in the list draws it whole in the pane, with
its state, description, facts, what it provides, and every action it
offers; the list rows keep only their name line and switch. The first
item is chosen when the page opens. The pane ends with the note on when
a change takes effect.

The page is the Settings surface opened on one destination, so every
extension read, refresh and model update stays one path. Settings opened
on its own keeps Extensions as a section that unfolds rows in place.
2026-11-05 02:54:26 +00:00
esengine 6a39ba41b5 docs(release-notes): usage page 2026-11-05 02:40:23 +00:00
esengine 0355736aaf feat(desktop): usage opens as its own page from the rail and the meter
Usage and cost becomes a page of its own under Data, and the rail's usage
button and the meter's wallet open straight onto it instead of onto the
Data section where it sat under memory. Landing on a block that is its
own page scrolls to the page's top, so the breadcrumb is in view.
2026-11-05 02:40:23 +00:00
esengine efa33a20f4 docs(release-notes): theme page 2026-11-05 02:34:18 +00:00
esengine 288a47b341 feat(desktop): the interface theme opens as a page, naming the one in use
Interface theme becomes a page of its own under Appearance: on the
section it is one row naming the theme in effect, and opening it shows
the palettes, the installed themes and theme import under a breadcrumb.
2026-11-05 02:34:18 +00:00
esengine 65a5f4fd53 docs(release-notes): agent presets page 2026-11-05 02:32:10 +00:00
esengine cfdc832472 feat(desktop): agent presets open as a page of their own
Agent presets, with the preset editor, the system persona and the mode
picker switch that live on it, move onto a page of their own under Agent
behaviour. The catalogue says which settings are drawn on another
block's page, so a search hit or link on the persona or the picker switch
opens the presets page rather than a section that no longer shows them.
2026-11-05 02:32:10 +00:00
esengine c6a5fb039b docs(release-notes): settings pages batch 2 2026-11-05 02:29:44 +00:00
esengine 29c7d74ce8 feat(desktop): automation, schedules, network, storage, export and versions open as pages
The second batch of settings blocks that are lists or editors moves onto
pages of their own: automation rules and scheduled runs under Extensions,
the network under Connections, storage and exporting or importing the
configuration under Data, and versions and updates under Account and
About. Each shows on its section as one row and opens under a breadcrumb.
2026-11-05 02:29:43 +00:00
esengine f5db50570c docs(release-notes): settings pages 2026-11-05 02:26:00 +00:00
esengine 948604dfee feat(desktop): settings blocks that are lists or editors open as pages
The settings catalogue can now say a block is a list or an editor rather
than a value. Such a block shows on its section as one row (its title,
its hint, what it holds now, and a chevron), and opening it gives the
column to that block alone under a breadcrumb back to the section. Its
contents mount only once it is opened. Search and links that land on
such a block open its page; changing section closes it; outside the
settings screen the block draws in place as before.

The first blocks given pages: models by use, the rules you spell out,
the tools the model can use, and messages between sessions.
2026-11-05 02:26:00 +00:00
esengine a867ab1466 docs(release-notes): first-run steps 2026-11-05 02:01:54 +00:00
esengine 7ee8ae0174 feat(desktop): first-run setup as steps: welcome, connect, default model
First launch opens on a welcome that says in one line what Reasonix does
and that setup takes about a minute. Setting up goes through steps shown
at the top, with Not now on each: connect a model service, chosen from
cards that name what each is (a relay or self-hosted entry is the only
one that asks for an address; a listed service says the address it
filled), then pick a default model from the models the key can reach.
Only a typed address that answers both ways asks how it is spoken to;
listed services never do. Steps slide in the direction they go, in the
panel duration, and still under reduced motion. Opening a project stays
the app's own page after setup finishes.
2026-11-05 02:01:54 +00:00
esengine 1cb5919859 docs(release-notes): phone access card 2026-11-05 01:57:25 +00:00
esengine 0dbb8abc1e feat(desktop): phone access card says what a phone can do and how it gets here
The device-access card is titled Use Studio on your phone and says what a
paired phone can do. Phones already connected come first, under one
count, each saying which way it came in. Then How the phone gets here
offers the two routes as their own blocks, same Wi-Fi first with its
address and its own switch, QR code and network picker, then From
anywhere with the account it needs. The routes stay separate: switching
one on never opens the other.
2026-11-05 01:57:25 +00:00
esengine ac07cd4967 docs(release-notes): remote machine cards 2026-11-05 01:54:50 +00:00
esengine 5f937aa60d feat(desktop): remote machines as cards with the next step on them
Each machine is a card: its dot, name, address and state as a tag on one
line, its folder and counts under it. A machine that is not connected
offers Test it right on the card; edit, test, hide from the rail and
remove move into the card's More menu, and the switch stays in view.
Machines found in ~/.ssh/config can be added together with Add them all.

The market install card's close button now names what it dismisses, so
it is not mistaken for the Settings sheet's own Close.
2026-11-05 01:54:50 +00:00
esengine 14e6b7c264 docs(release-notes): skill menu 2026-11-05 01:47:57 +00:00
esengine 8a18aa87e6 feat(desktop): skill actions behind a More menu, update and uninstall as cards
Updating a skill from its source folder and uninstalling it move off the
row into a More menu, so a skill row is its name and marks, description,
where it lives, and its switch. Uninstall is the danger-coloured item.
The update and uninstall confirmations become cards: the question as the
title, warnings as tinted boxes rather than edge bars, and the buttons at
the foot.
2026-11-05 01:47:57 +00:00
esengine 92d544fc78 docs(release-notes): publish form 2026-11-05 01:45:40 +00:00
esengine 4e74324227 feat(desktop): publish form and my publications as the board lays them out
Publishing is a titled card. The type comes first and the source address
straight after it, since what the package is decides what the source may
be; name and version follow side by side, then the rest. Visible only to
me is drawn as a switch in a tinted row, and the footer holds the note
beside the submit button. In My publications each version says where it
stands as a coloured dot and a word: public, in review, not approved,
private.
2026-11-05 01:45:40 +00:00
esengine a4c94f26cd docs(release-notes): market package detail 2026-11-04 18:52:10 +00:00
esengine d66f91ea27 feat(desktop): market package detail and cached-data notice restyled
A market package opens as a card: its name, kind and trust tags on one
line, the summary, the facts in a tinted box, the vote row, and a footer
that holds the note beside Back and the next step. Kind and trust read as
tags in the list too. The unpinned warning and the notice that the list
is from the cache are tinted boxes rather than edge bars, the cached one
with Retry on its own line.
2026-11-04 18:52:10 +00:00
esengine 7416015e68 docs(release-notes): market install confirmation 2026-11-04 18:50:36 +00:00
esengine 954c8ff8e6 feat(desktop): market install confirmation as a titled card
The confirmation every market install passes is a card titled with what
will be installed and a close button. A pinned version opens with the
digest check in the success colour and the digest itself beneath; an
unpinned or own version keeps its warning first. Warnings and the
read-each-of-these group read as tinted boxes instead of coloured bars
down the edge. The kernel's risk grouping is unchanged.
2026-11-04 18:50:36 +00:00
esengine e9fbb51d45 docs(release-notes): skill rows 2026-11-04 18:46:54 +00:00
esengine 263344eae7 feat(desktop): skill rows on two lines, drift beside the name
A skill row is its name (or command) with what it is — drift from its
source, subagent, read-only — on the first line and its description on
the second; where it lives is a tag, then the switch. Drift reads in the
warning colour, and a source folder that is gone in the error colour, so
it stands out of the list rather than waiting in a menu. A failed
service is marked by its red dot alone: the red bar down its left edge
is gone.
2026-11-04 18:46:54 +00:00
esengine 0dca195e5c docs(release-notes): extension rows 2026-11-04 18:42:19 +00:00
esengine e7bcc1e4b4 feat(desktop): extension rows carry a name, one line, and a switch
A package or service row is its status dot, its name in full (never cut),
one line under it saying what it brings and where it came from, and its
switch. Update, export, remove, reconnect and sign-in move into the row's
body, and a row with something wrong opens by itself so the reason and
the way to answer it are in view without a click.
2026-11-04 18:42:19 +00:00
esengine 8a58309a39 docs(release-notes): extensions add menu 2026-11-04 18:37:18 +00:00
esengine 80b9d44fb0 feat(desktop): extensions open with one Add… menu, runtime note at the foot
Everything that puts something into the extensions list sits behind one
Add… button beside the Installed / Discover tabs: install from the market,
from a repository address, import a local folder (which opens the folder
picker straight away), or connect an external service. A way this window
cannot take right now is left out of the menu: no new install while a
package is mid-update, no service while the kernel is not live. The
per-group Add and Connect buttons are gone.

The runtime card no longer leads the page. It sits at the foot of the
installed list as one line saying that enabling, disabling, installing and
updating take effect from the next turn, with Reload runtime beside it for
the one case that still needs it: an extension's own code edited on disk.
2026-11-04 18:37:18 +00:00
esengine ccd4e35459 docs(release-notes): feedback form 2026-11-04 18:22:25 +00:00
esengine 7480ae88fa feat(desktop): the feedback form laid out as the board draws it
Categories are cards that say what each is for; the body keeps its
preview toggle and byte count, with the public-if-filed line beneath it;
screenshots sit in one row with Capture this window and Add images, and
the warning about what a capture carries sits under them. Nickname and
contact keep their row. What is sent along reads as one line that opens
to the full environment and the logs folder; the security policy link
moves to the footer beside Send. The discussions pointer goes, since the
form itself now takes ideas and questions.

The receipt is a check, a short thanks, the receipt number in a box with
an icon copy button, what happens next, and View my feedback or Write
another. Legacy rules for the replaced classes are removed.
2026-11-04 18:22:25 +00:00
esengine 2688439ae2 docs(release-notes): office previews 2026-11-04 18:13:53 +00:00
esengine 3d50eddccc feat(desktop): Excel and Word previews laid out as the board draws them
The read-only note stays at the top beside a read-only tag, with zoom out
and in on both kinds and fit width for Word; a workbook switches sheets
from a tab strip at the bottom that also says when rows or columns were
cut. With Office preview off the preview names the file and offers both
ways out: open it in the system application, or turn the preview on in
Settings, which the workbench now reaches through the pane.
2026-11-04 18:13:53 +00:00
esengine 083e0b3369 docs(release-notes): PDF toolbar 2026-11-04 18:04:08 +00:00
esengine a87cb3ac52 feat(desktop): PDF toolbar and the PDF that will not open
The PDF preview's toolbar is the board's: icon buttons for the previous
and next page around a page box that takes a typed number, held to the
document's page count; zoom out and in step through fixed scales from
whatever fit-width last drew, with the drawn scale between them; fit
width is a toggle. A PDF that cannot be read (damaged, or asking for a
password) gets one strip saying so with Open in system application.
2026-11-04 18:04:08 +00:00
esengine 5d80ed0c50 docs(release-notes): browser login dialog 2026-11-04 17:54:41 +00:00
esengine 8ab2315dd7 feat(desktop): browser login dialog laid out as the board draws it
The sign-in a page or proxy asks for in the built-in browser gets a titled
dialog with a close button, the site's own prompt as it sent it, labelled
fields, the note on where the password goes under a lock, and a footer
holding Cancel and Sign in. A proxy keeps the same dialog under its own
title.
2026-11-04 17:54:40 +00:00
esengine d8ae172f20 docs(release-notes): open-with split button 2026-11-04 17:52:08 +00:00
esengine fbcd7d077d feat(desktop): open-with as a split button with a titled menu
The open-with control remembers the last program chosen and opens the
project in it from its left half; the arrow lists every program. The menu
is titled, draws each program as its initial on a tile coloured by the
kernel's id (the kernel ships no icons), sets the file manager apart under
a divider with the platform's own wording, says when nothing was found,
and ends with the project's path.
2026-11-04 17:52:08 +00:00
esengine 845da7ab45 docs(release-notes): restore the blank line under the English marker 2026-11-04 17:34:28 +00:00
esengine 543c28d7e5 docs(release-notes): conversation selection bar 2026-11-04 17:34:01 +00:00
esengine 6affac79d3 feat(desktop): selection bar in the conversation, above the selection
Selecting text in a finished reply raises the same small bar the editor
has: quote selected text, or copy it. It appears 120ms after the pointer
lets go, never takes focus, and gives way to the context menu, a
collapsed selection or a scroll.

Both bars now share one component and sit above the selection as the
board draws them, dropping below only where the scrolling area leaves no
room above; the editor's bar carries its line range as a trailing note.
2026-11-04 17:34:01 +00:00
esengine 00d05b1069 docs(release-notes): editor selection toolbar 2026-11-04 17:09:12 +00:00
esengine 12aa7764cf feat(desktop): selection toolbar in the editor, text menu in board order
Selecting code in the workbench editor raises a small bar under the
selection 120ms after it settles: add to conversation with the line range
it covers, or copy. It closes on collapse, scroll or the context menu, and
a selection ending at a line's first column does not count that line.

The in-page text menu now leads with add to conversation, then cut, copy,
paste and select all with their platform shortcuts, then undo and redo,
each group separated.
2026-11-04 17:09:05 +00:00
esengine 2942eead2c docs(release-notes): workbench menus 2026-11-04 16:35:13 +00:00
esengine fb1cbc5476 feat(desktop): the file and browser-tab menus follow the WB-Menus board
File menu: a header names the file and its folder ("根目录" at the top);
加入对话 comes first, then 复制引用 with the @reference it copies shown on the
right, then 复制路径; a rule, then reveal and open in the system app. Each
item carries its icon. An unusable reference keeps its item, greyed, with the
reason beneath it. A copy that lands says so in the bottom note capsule
("已复制 @README.md"); one that fails stays in the menu until the next try.

Browser tab menu: a header names the tab ("浏览器标签 · localhost:5173"), and
each close choice shows how many tabs it would close. Accessible names stay
the items' own words, without the counts or references drawn beside them.

The keyboard test for the file menu walks to 复制引用 rather than counting a
fixed number of steps, since the board moves it.
2026-11-04 16:35:12 +00:00
esengine d1fccb0db2 docs(release-notes): workbench state cards 2026-11-04 16:13:00 +00:00
esengine 07c77d946f feat(desktop): the workbench's empty and failed states are cards, as the WB-Parts board draws them
One WorkbenchState card (icon, what it is, why, what to do) now carries:
- a file that is no longer there: "文件已不在原处 · 可能被删除或移动了", with
  重新读取 (reads it again) and 关闭页签;
- a document over the preview limit: "文件太大,无法预览 · 48 MB,预览上限 32 MB",
  with 用系统应用打开 in the card (the toolbar's copy steps aside there);
- a folder without git: "这个文件夹不是 git 仓库 · 改动和提交历史需要 git".
A page that does not load keeps its own BrowserFailure, drawn as the same
card with a globe. The board's 在此初始化… is not offered: no kernel route
initialises a repository.
2026-11-04 16:13:00 +00:00
esengine 86b1663194 docs(release-notes): unsaved close prompt and workbench slide 2026-11-04 16:03:07 +00:00
esengine 29c042439b feat(desktop): closing an unsaved tab asks first, and the workbench slides in and out
Closing a file tab with unsaved text opens a small prompt under it, as the
WB-Frame board asks: 保存 saves that file (any open one, not only the file on
screen: the files hook gains saveAt and dropAt) and then closes it, 不保存
drops the draft and closes, 取消 keeps the tab and its text.

The workbench slides in 24px from the right over 200ms when it is brought
out, never when it is drawn already out; its own close button slides it back
out over 160ms before putting it away. Reduced motion skips both.
2026-11-04 16:03:06 +00:00
esengine e0f087d0cc docs(release-notes): workbench tab states and motion 2026-11-04 15:49:55 +00:00
esengine 3ada521676 feat(desktop): workbench tabs carry their state and move as the WB-Frame board draws them
States: a file with unsaved text shows a dot where its close button is, and
the close button again under the pointer (the files hook now reports which
open files are unsaved); the page the model is working on keeps its blue dot,
now after the name and pulsing every 1.6s; Go, Python and sheet files colour
their icons apart. Tabs keep their width; the ones the strip cannot show fold
into "+n ⌄", a list to pick from that closes on Escape or on leaving it.

Motion: a tab that opens after the strip was drawn grows in over 180ms (one
drawn with the strip does not move); closing narrows a tab to nothing over
140ms before it goes; switching fades the canvas in over 120ms and moves the
tab's colours over the same time. Reduced motion stills all of it.

The tab strip's ref is now owned by useOverflowTabs, so the scroll listener
it installs has a receiver the action census can name.
2026-11-04 15:49:55 +00:00
esengine 0b9f9ba258 docs(release-notes): file toolbar and diff 2026-11-04 15:25:17 +00:00
esengine a7fbbe1797 feat(desktop): the file page's toolbar ends in "…" and its Diff reads as the board draws one
The file bar is path, view toggle, save, then "…", which opens the same file
menu the tree's right-click does (reveal, copy path, copy reference, add to
the conversation) under the button; the separate reveal icon is gone. A
reveal asked for from that menu reports a refusal beside the file, where it
was asked, rather than in the explorer's notice area that may be hidden.

The file page's Diff is drawn by the changes page's renderer, now
UnifiedDiff.tsx: the line before and after numbered, the @@ header kept, the
full width of the page; a click on a number goes to the file view.

The workbench's UI action entries move to actions_workbench.ts, the pattern
actions_feedback.ts set, to keep actions.ts under its line ceiling.
2026-11-04 15:25:17 +00:00
esengine 7196c57600 docs(release-notes): explorer sections 2026-11-04 15:14:08 +00:00
esengine 93b39b7559 feat(desktop): the explorer's three sections fold and read as the WB-States board draws them
改动, 提交历史 and 文件 each head with a fold arrow and the board's title
weight. The 改动 title opens the changes page (when there are changes), its
count reads "4 个文件", and 提交… is the dark button between 暂存全部 and
推送…. 提交历史 is open from the start, names the branch on the right, and
draws each commit as its short hash, subject and age, with the author in the
tooltip; the lane graph is drawn only when the history actually forks. The
file tree gains its 文件 header. Fold buttons are named "收起改动" / "展开文件",
so they do not share a name with the toolbar's Files button.
2026-11-04 15:14:08 +00:00
esengine b83be788f4 docs(release-notes): workbench changes page 2026-11-04 14:12:13 +00:00
esengine a922116e9d feat(desktop): the changes page, as the WB-Frame board draws it
A workbench tab "改动 n" lists every changed file of the workspace's own
repository on one page. Its head names the branch, how many files and
+/− lines, and carries 暂存全部, 提交… and 推送… (the explorer's own commit and
push cards). Each file has a sticky header with its fold, staged checkbox,
git status letter, path and +/−; hovering it offers 打开文件 and 丢弃, which
asks once more before the file goes back to the last commit. An edited
file shows its diff at once with the line before and after numbered and the
@@ header kept; a new or untracked file starts folded behind a line saying
what it is. A diff is read only once its file is open and the page shown.

The composer's "n 个文件已更改" pill now opens this page; the Files press
that used to share its signal has its own (revealFiles), so it still opens
the explorer. The workbench-browser race tests keep their guarantee, that a
browser finishing late does not take the page over, now stated for the
changes page.

The file tree moves out of WorkbenchPanel.tsx into WorkbenchFileTree.tsx to
make room under the file's line ceiling.
2026-11-04 14:12:13 +00:00
esengine 4f0cd15d62 docs(release-notes): workbench tabs and status line 2026-11-04 13:04:45 +00:00
esengine f28fdf47f1 feat(desktop): the workbench's tab strip, new tab and file status line follow the WB-Frame board
Tab strip: the explorer is the fixed first tab and carries the session's
change count; a new tab shows as "+ 新标签" in the strip while it is open;
the strip ends with a button that closes the workbench, which the pane maps
to the same view change the top bar's toggle makes.

New tab: left-aligned "在工作台打开" with three entries, 资源管理器 (the change
count on it), 文件… (opens the explorer with its search focused) and 浏览器,
then 最近打开 as a plain list with each file's folder in mono. The separate
"改动" entry is gone, as on the board; the count rides on the explorer.

File page: a status line under the editor names the file's language, the
line the cursor is on (CodeEditor now reports it) and 未保存 while the draft
differs from the file. It states no encoding: the workspace read carries
none, and naming one would be a claim nothing checked.
2026-11-04 13:04:44 +00:00
esengine 4c043f7b50 docs(release-notes): thought crossfade and step clock 2026-11-04 12:52:07 +00:00
esengine 62b5435558 feat(desktop): the thought's title crossfades, and a running step counts from its own start
When a thought this card watched finishes, the running title "思考中 · 6.2 秒"
fades out over 200ms in the same place as "思考 6.2 秒 · 214 字" fades in,
instead of the new title alone fading up; the old one is gone after 240ms.

A running step in the activity group counts its seconds from the kernel's
startedAt, so a row drawn late (a reload, a pane brought forward) shows how
long the command has really run; a call with no start still counts from its
row.
2026-11-04 12:52:07 +00:00
esengine 0692adbaaf docs(release-notes): activity group motion 2026-11-04 12:49:54 +00:00
esengine f90ab3ccec feat(desktop): the activity group moves and reads as the Think-Exec board draws it
Title: folded while it runs, it says the step under way and how long, with a
spinner and a faint sweep across the row ("执行过程 ⟳ 运行命令 go test ./... ·
12 秒 … 3 项操作"); open, it counts what runs; a failure keeps "1 项失败"; once
done it reads "执行过程 ✓ 4 项操作 · 38 秒", the time measured from the first
call starting to the last ending (else their durations added). The action
count rolls up when it changes under the reader, never on first draw, and
the group folds over 240ms.

Rows: the right side says what a step came to (48 行, 2 处, +12 −3, 退出 1, or
its seconds) instead of a duration for everything. A running step has a
spinner where its tick will be; the tick pops when it finishes under the
reader. A step that fails under the reader shakes once, and its failure sits
in a red box under the row (a host refusal, else the command's last output
line) with "看完整输出", instead of the whole call body opening. A step running
10s with nothing to show says "还没有输出 · 停止这一条". Rows entering together
fade up 4px over 180ms, 30ms apart, at most five apart.

ActivityGroup's title moves to ActivityHead.tsx, which keeps Transcript.tsx
inside its line ceiling. Reduced motion stills every one of these.
2026-11-04 12:49:53 +00:00
esengine a5de9732e4 docs(release-notes): thought folds and caret 2026-11-04 12:30:48 +00:00
esengine ad1a116854 feat(desktop): a thought keeps the reader's fold across reloads, and the answer's caret sits on its last word
A thought the reader opened or closed is remembered per conversation and
drawn that way after a reload. Card ids are minted per load, so a thought is
named by its turn's user message index and its place among that turn's
thoughts; an unsaved conversation remembers for this load only. The pane's
memory keeps one identity per conversation and reads the latest items
lazily, so a streamed token re-renders no card through it.

While an answer streams its caret is drawn after the last word of the last
paragraph or list item rather than on a line of its own, and it blinks once
a second with a hard cut, as Run-Wait specifies.
2026-11-04 12:30:48 +00:00
esengine ad6ab61c10 docs(release-notes): thinking block 2026-11-04 12:25:04 +00:00
esengine 6cf29ac1b7 feat(desktop): the thinking block follows the Think-Exec board
While the model thinks the title reads "思考中 · 3.2 秒" and a light sweeps
across it every 1.6s; nothing else on the card moves. Opened while live, the
reasoning grows to six lines and then scrolls, the newest line at the bottom
with the caret. Once the thought ends the sweep stops and the title fades
(200ms) to "思考 6.2 秒 · 214 字", seconds first with one decimal; a thought
that opened on its own folds again over 240ms, one the reader opened stays.

A turn stopped or failed while the thought was still arriving marks it cut:
the title turns amber, "思考到一半停下了 · 3.1 秒" or "思考到一半出错了 · …",
with a line saying what was thought so far is inside. The title change only
animates when this card watched the thought finish, never on remount.
Reduced motion shows plain grey text and switches without animating.
2026-11-04 12:25:04 +00:00
esengine 2f40d46434 fix(desktop): a landing stops gliding once its transcript has left the document
The 300ms glide added for the message rail kept queuing frames after the
transcript was unmounted, which nest-mount's settling-landing test caught;
it now stops on the same condition the settling pass already checks.
2026-11-04 12:25:03 +00:00
esengine 4d85f9882f docs(release-notes): feedback two columns 2026-11-04 11:57:09 +00:00
esengine 17f5a5621e feat(desktop): My feedback is a list beside one report's page, as the Fb-Mine board draws it
The dialog opens wide on My feedback: the list on the left carries each
report's title, category and day, a waiting question as an orange
"等你回复" first in the order, and where it stands in one word (已收到,
已登记 #n, 处理中, 已在 vX 修复, 下个版本修复, 暂不做, 和 #n 重复); a report
with new replies is bold with a dot. The right is that report's page: title,
chips, receipt with copy and a "…" menu holding withdraw, the timeline across
the top, the whole exchange as bubbles with "你 · 10-30 14:02" above each,
and the reply box always open at the bottom with 附图 and 发送.

Replies are read once their report is the one on the right, so the folded
thread, its toggles and the Reply/Cancel buttons are gone; an unsent reply
is kept per report while moving between them. The header carries the tabs
(写新反馈 / 我的反馈 with the waiting count), the level line "反馈等级 n ·
已采纳 n 条" that opens level, account and leaderboard in a popover, and the
close button; the full-width toggle is gone. The refresh note and the
earlier-identity warning are small print under the list.
2026-11-04 11:57:09 +00:00
esengine 1a06fb1167 docs(release-notes): message rail 2026-11-04 11:32:00 +00:00
esengine c370090b8b feat(desktop): the message rail spreads over the column and shows where you are, as the Msg-Rail board draws it
Marks sit at each message's place in the whole conversation (a mounted
message by its own box, an unmounted one by its block), filling the column
instead of a compact cluster in the middle. A grey band follows the viewport
and the marks inside it draw darker; a message that changed files draws
longer; stars are orange. Hovering grows the pointed line and two neighbours
each side (22/16/12px) and opens a card: "你的第n条 · 改了n个文件", two lines of
the message, and the click/right-click hint. Marks closer than 5px join into
segments of at most 56px; hovering one lists its messages with their numbers
and stars, and a press on bare track lands on the mark at that height.
Unloaded earlier messages show as a count pill at the top with a tooltip.
Ctrl+Up/Down rings the mark it walks to and shows its card. A landing on a
mounted message glides over 300ms and the message flashes. The rail needs
three of your messages, is hidden in the folded narrow layout, and reduced
motion switches without animating.

The test for BackgroundJob imported Tool from the port, which does not
export it; it comes from the wire module.
2026-11-04 11:31:51 +00:00
esengine f4b199cc68 docs(release-notes): background start row 2026-11-04 11:07:07 +00:00
esengine 200db241af feat: a command sent to the background names its job, and its row follows that job, as the Bg-Jobs board draws it
A background start carried its job's id only in the sentence the model reads,
so a window could not tie the call to the job without parsing it. The shell
execution now carries jobId on every layer to the wire. In the execution group
that call reads "放到后台运行", its row shows the job's live state from the
pane's job list — running and for how long, then how long it took and its exit
status — and while it runs a note says the turn goes on without it, with a way
to its output.

The run tabs hook no longer writes its list when a request adds nothing, which
re-rendered without end for a caller passing a fresh setter each time; the job
tab test that did so and hung its worker now keeps one. Execution's comment in
wire.ts states what the type is for rather than how it came to be.
2026-11-04 11:07:07 +00:00
esengine d6a51d576b docs(release-notes): job output tab 2026-11-04 10:58:19 +00:00
esengine bf34fd5d3a feat(desktop): a background job's output opens as a workbench tab, as the Bg-Jobs board draws it
"看输出" no longer covers the conversation with a dialog: the job opens as a
workbench tab, the same way a sub-agent's run does — one list of run tabs, one
open path, one close path. The tab names the job, reads how long it has run or
how it finished ("跑完了,退出 1 · 4 分 02 秒"), stops it while it runs, and
shows the command with what it printed. It follows the newest output until the
reader scrolls up, and an arrow brings it back. The dialog stays the fallback
where the pane has no workbench to open it in.
2026-11-04 10:58:19 +00:00
esengine 9b74480018 fix(desktop): the bench's sample jobs live beside its sample sessions, under the mock's line ceiling 2026-11-04 10:46:21 +00:00
esengine 90638d6ff3 docs(release-notes): background job list 2026-11-04 10:45:08 +00:00
esengine 7d2e9b9457 feat: finished background jobs stay listed with how they ended, and the rail's job list follows the Bg-Jobs board
The status surface carried running jobs only, so a job that ended left no
trace there: the rail could never read "failed", and a person had no way to
see how long a finished build took or how a test run exited. /status now also
carries endedJobs — this session's finished jobs, kept until the session is
purged — and every job view carries the last line it wrote; an ended one adds
when it ended and, for a command, its exit status. The last line is what the
command wrote itself, kept before the finished job's in-memory tail is
released, not the host's note of how it failed. jobs stays the running list,
since that is what its readers take it to be.

The rail's list follows the board: running jobs above ended ones, each with
how long it ran, what it runs and its last line; an ended one with its exit
status; stop asks again before it acts; ended jobs can be cleared from this
window. The reading is a blue pill while one runs, stays red for a failure
until the list has been opened on it, and is grey once all have ended.
Durations pad seconds as the board writes them (4 分 02 秒).
2026-11-04 10:45:08 +00:00
esengine 01681c2083 fix(desktop): text that skipped t() reads as words again, and every test watches for a raw key
Keying the wording tables also keyed a few strings whose readers never pass
them through t(): two thrown errors and the empty-body line in a memory's
detail. Those now translate where they are made. The test setup watches every
rendered text node and readable attribute, and fails a test whose screen shows
a message key, so a call site that skips t() cannot reach a person as
"plan.action.start".

Tests that check the wording tables read them through the keys they now hold.
2026-11-04 10:17:19 +00:00
esengine f712df203d refactor(desktop): the frontend's own wording tables name their messages by key
The tables a call site hands to t() later — the kernel refusal and notice
wording, the settings index, glyph names, run states and the rest — now hold
keys, and their wording joins i18n/msg, the kernel's under kernel.ts. Search
keywords stay as they are: they are matched against what a person types, not
translated. Text the kernel itself sends in Chinese still resolves through
en*.ts, which stays until the kernel names those by code.

Notice wording reads a message's placeholders from its Chinese, not from its
key, and the checks read both tables through the key. Two test files broken
by the earlier test rewrite (overlapping edits in one line, and an import left
unused) are whole again.
2026-11-04 09:57:50 +00:00
esengine 9c0b515b94 fix(desktop): keep the English for Chinese the kernel sends, and key the tests that held it in constants
The key batches dropped every en*.ts entry no frontend source still named.
That cannot tell an unused string from one the kernel sends at runtime — a
preset is named 日常搭档 on the wire and the window translates it on arrival —
so those reached English windows in Chinese. The legacy catalogue is back
whole until each of its readers is accounted for; it still carries the three
settings strings and the two de-duplicated keys.

Tests that kept a Chinese template in a constant now keep its key, and
find-paging no longer mocks t() into echoing its argument.
2026-11-04 09:39:12 +00:00
esengine 08d257b629 test(desktop): tests ask for English by the key that now carries it
Tests that compute the expected text with t("中文") in an English window
looked up an English entry the key batches had removed, and compared the page
against Chinese. They now pass the message's key, as the components do.
2026-11-04 09:31:00 +00:00
esengine 15ca4a7a5c refactor(desktop): the remaining areas name their messages by key, and no t() call passes Chinese
Appearance, onboarding, about, safety, settings, panels and the app shell move
the same way, along with the three calls whose text sat in a nested ternary
and the effort descriptions, which a table now names by key. The table check
counts a key named anywhere in the sources as asked for, since a table handed
to t() is still asking.
2026-11-04 09:12:45 +00:00
esengine 7656b39895 refactor(desktop): the extensions, memory, usage, data, models and persona areas name their messages by key 2026-11-04 09:11:14 +00:00
esengine bd29399c9b refactor(desktop): the workbench, session list, remote and feedback areas name their messages by key
Same move as the conversation areas: each call passes a key, the wording in
both languages lives in i18n/msg/<area>.ts, keys already named in common are
reused, and the English entries nothing names any more leave en*.ts.
2026-11-04 09:10:38 +00:00
esengine e833af629b refactor(desktop): the conversation, composer, plan and subagent areas name their messages by key
Every t()/plural() call in these areas now passes a key ("chat.delivered_files",
"composer.task_input"); its Chinese and English sit together in
i18n/msg/<area>.ts, and a message used by more than one area lives in common.
Keys take their meaning from the English already in the catalogue. The
English entries no source names any more are gone from en*.ts.

The catalogue checks pass whole for the first time on this branch: the three
settings strings that had no English have one, and the two keys defined twice
keep the reading that already won at runtime.
2026-11-04 09:09:08 +00:00
esengine 4ba8900a16 feat(desktop): messages are named by key, with both languages in one table
A message is now t("plan.action.start"), and its Chinese and English sit side
by side in i18n/msg/<area>.ts, so neither can be edited without the other in
view. The plan card and strip, the subagent group, its rail list and its
workbench tab are the first areas moved; the rest still pass the Chinese
source text, which t() keeps resolving until each area moves.

The new tables are held by msg.test.ts: every key is area.meaning and lives in
its area's table, both languages are written with the same placeholders and no
Chinese in the English, every key the interface asks for exists, none goes
unasked, and none is defined twice. The English entries the moved areas no
longer use are gone from en*.ts.
2026-11-04 09:04:23 +00:00
esengine 1bcb35a395 test(desktop): the empty-session brand check reads the design sheet the rule moved to
The redesign moved .hero .sym-mark from app.css into design/shell.css, so the
check found no rule and failed though the brand still does not animate.
2026-11-04 09:01:01 +00:00
esengine 492318c235 docs(release-notes): subagent workbench tab 2026-11-04 08:52:30 +00:00
esengine 2d3b791634 feat(desktop): a subagent's workbench tab follows the Subagent board
The tab carries the delegate's colour avatar. Its head names the delegate in
mono with its model, and while the run is live offers "停止子代理", which
stops the call that spawned it. The run's first user message, the task the
main session handed over, heads the tab in its own box instead of reading as a
line of the run. The foot says the conversation is read-only and goes back to
the delegate in the conversation through the transcript's own landing.
2026-11-04 08:52:30 +00:00
esengine 0214f174e2 docs(release-notes): subagent rail reading 2026-11-04 08:46:34 +00:00
esengine 3fafc4a528 feat(desktop): the run rail's subagent reading and its list follow the Subagent board
While a delegate runs the reading is a blue pill with a breathing dot, "N 个子
代理在跑"; once all have ended it is grey, "N 个子代理 · 都结束了", counting
delegates rather than dispatch calls. Its list is the board's: a title with
running and total, one row per delegate with the same fixed-colour avatar as
the conversation's group, its name, task and state, a row opening its run,
and a stop-all that goes to the port for each running delegate.
2026-11-04 08:46:34 +00:00
esengine 9b89318ef5 docs(release-notes): subagent group card 2026-11-04 08:42:48 +00:00
esengine a0a50379ce feat(desktop): delegates sent out together are one group card, as the Subagent board draws it
Two or more consecutive dispatches in a run of work read as their own card:
"N 个子代理并行" with running, failed and delivered counts and a stop-all,
then one row per delegate with its colour avatar (fixed per name), its name
and task, and where it stands — the step it is on, delivered with steps and
time, or failed with the kernel's reason written out. A running row says what
it is doing now and can be stopped alone; every row opens its run in the
workbench through the pane's existing opener. When all have delivered the card
folds to a summary; a failure keeps it open. A lone delegate stays a row of
the execution group, as the board's fifth case does.

Cause: three parallel delegates folded into "执行过程 · 1 项运行中 · 1 项失败",
which said neither who they were nor which one failed.

Neighbouring behaviours tested: lanes around ordinary calls, a lone delegate,
stop one and stop all through the port, fold after delivery and not after a
failure, opening in the workbench, the reads and plan work around it.
2026-11-04 08:42:48 +00:00
esengine 24a506d553 chore(repolint): lower the budgets the deleted account row freed 2026-11-04 08:32:30 +00:00
esengine 0155e10ca2 docs(release-notes): one row per read 2026-11-04 08:32:08 +00:00
esengine cd1519f4d3 fix(desktop): clear the census and catalogue debt this branch's redesign added
The sidebar foot spelled its files check inside data-action, so the action
census read "files" as an action; the account row lost its last caller when the
account entry moved into settings, and its strings and styles went with it, so
the nav's own account entry names chrome.account where the census can see it;
the memory detail passed its group titles as bare literals. The action, orphan
and literal checks are back to the pre-branch baseline.
2026-11-04 08:32:00 +00:00
esengine 002a3216fe feat(desktop): each read in the execution group is its own row, as the boards draw it
A run of consecutive reads is still folded in state; the execution group now
spreads it back into one row per call, so it reads "读取 scripts/export.py"
row by row like every other call instead of one "读取了 2 个文件" summary.
The group's count was already the number of calls.
2026-11-04 08:32:00 +00:00
esengine 3c175b156d docs(release-notes): plan card and strip 2026-11-04 08:26:02 +00:00
esengine 00601023cc feat(desktop): the plan is one card in the conversation, with the strip above the composer only while it is out of view
The plan card follows the Plan-Card and Chat-Plan boards. Proposed, running,
waiting on a person and finished are the same card changing in place: numbered
steps before it starts, then the kernel's live list with a done mark, a turning
mark on the step in hand, and a badge for where it stands. The work after a
started plan is filed under its card, and the plan's own todo_write rewrites
are left out of that work because the card already draws the list.

The strip above the composer is the card's stand-in: a tab inset 14px with the
step it is on and one segment per step, shown only while the card is off
screen and no decision is waiting. Opened, it is the same tab grown upward,
titled once, with a way back to the card through the transcript's own landing.

Cause: the pending card was one sentence and three buttons, because the plan
request carried no steps, and the live list lived only in the strip, so the
plan was shown twice once it ran. The kernel now sends the starter list the
approval seeds as the plan request's input, from the same parser, and the live
event names its kind as the replayed one already did.

Blast radius: plan approvals on every frontend (the TUI keys its plan answers
on kind, which only the replay carried), the wire form of a plan approval, the
transcript's work layout around a started plan, and the plan-progress
preference, now on unless a person turned it off.

Neighbouring behaviours tested: replayed plan prompt, seed equals the card's
list, unscoped calls still send no input, revise/exit cards, fold-work folding
a card's work, refused todo_write kept, strip collapse/escape/outside press,
goal-only strip, progress preference.

Why this layer: the controller is the one place that parses a plan into steps,
so the card shows what approving starts rather than a second parse.
2026-11-04 08:25:53 +00:00
esengine 91e4854d2f docs(release-notes): feedback, reasoning and plan strip 2026-11-04 07:04:21 +00:00
esengine 627ee68b83 feat(desktop): reasoning and the plan strip follow the Think-Exec and Plan-Card boards
Reasoning heads with a 13px semibold muted line and opens to 13px/1.75 muted text indented 18px, set apart from the answer by 8px. The plan strip is the composer's own top row, as the board's "pinned above the input" state draws it: 36px on the page tone with a hair line under it, the label and count in 13px semibold, the current step muted, and, when plan progress is shown, one 14px segment per step — done in green, current in blue, waiting in the border tone — while the progress bar stays for screen readers.

An activity row whose call has no verb of its own (update plan, save memory, and the like) reads its tool label again instead of "Other".
2026-11-04 07:04:21 +00:00
esengine 16bc3c6919 style(desktop): the feedback center takes the Fb-Mine board's controls
The send/mine tabs are a segmented control; the progress filters are 26px pills whose chosen one takes the accent border; status reads as a 20px filled pill — amber when it waits on you, the brand tint while in progress or answered, green once fixed, neutral otherwise; a report's title is 13px semibold over an 11px faint line; the current step of its timeline takes the brand tint. The sheet sits on the surface tone. The status chips stay on the decorative brand token rather than the net status colour, and the token test follows the new selectors.

The board's list-and-detail split is not part of this change: every reply box and thread is scoped to its own list item today, and moving them out is a behaviour change of its own.
2026-11-04 06:59:11 +00:00
esengine 520885013a style(desktop): the icon tooltip takes the Spec-Overlays padding and a quiet shortcut
Spec-Overlays draws a tooltip as 5px/8px padding with the name in 13px semibold and the shortcut in regular faint ink; the shortcut had inherited the name's weight in the muted ink. The Rail-Foot board, which drew a dark tooltip, now uses the spec's too.
2026-11-04 06:51:25 +00:00
esengine bd18fe8f9d fix(desktop): the selected settings category is semibold, as the NavItem spec draws a selected item
Spec-Lists draws a selected NavItem at 600. The settings boards wrote font-weight: 600 before a font shorthand that reset it to 500, so the left column had been matched to the slip; the boards are corrected on the canvas and the column now follows the spec, like the session rows and the memory rail.
2026-11-04 06:26:31 +00:00
esengine 6e72cc30e8 docs(release-notes): memory page and type settings 2026-11-04 06:21:32 +00:00
esengine 0f3f03aa93 fix(desktop): type in the design sheets follows the text-size and weight settings, and text is smoothed as the canvas draws it
The design-layer sheets wrote sizes as bare pixels and weights as bare numbers, so the text-size and weight settings could not move anything they drew, and the fontscale and spec-literals gates never saw them because they read src/styles only. The gates now read src/design too; sizes ride the six size tokens (board in-between sizes like 11.5 and 12.5 snap to the nearest step), weights ride --w-reg/--w-med/--w-semi, and radii ride the radius tokens.

Text is antialiased everywhere, not only in the macOS desktop shell: a browser on macOS drew the same weights visibly heavier than the design canvas, which is antialiased. The property does nothing on Windows or Linux.
2026-11-04 06:21:24 +00:00
esengine 0c428a31f8 feat(desktop): the memory page follows the Mem-Review board
Confirm and edit are 24px pills in the detail's header beside the title, with archive as an icon button there, so the actions stay in view instead of sitting under the facts. The facts drop their icons and read as two groups of four, content and use. A list row is title, the new badge, and a word only when the memory is fading or forgotten; the clear/fuzzy/faded glyph is gone, and the second line names the type before the galaxy.
2026-11-04 06:21:23 +00:00
esengine aa1fcea7e9 docs(release-notes): workbench and palette 2026-11-04 06:12:20 +00:00
esengine ca167b6c47 feat(desktop): the workbench and the command palette follow their boards
Workbench (WB-Frame, WB-Parts): a raised panel with the float shadow; a 42px tab strip on the page tone with the file-tree tab first, the active tab a raised folder tab of 35px that sits into the panel, others 34px and quiet; a 40px path bar whose segments are faint 12px mono with the file name in semibold text; the read/edit/diff choice as a 24px segmented control; Save as the 32px primary button, outlined while there is nothing to save. The editor sits on the raised surface. Its default width is 520 and the drag starts at 360 with a 900 ordinary limit, as the board's width rule states.

Command palette (Cmd-Palette): 14px corners, a 52px search row at 15px with an Esc key, 11.5px section labels, 36px rows that light with the float highlight, and a 36px footer on the page tone.
2026-11-04 06:12:20 +00:00
esengine b2db4be204 docs(release-notes): file cards and flat empty state 2026-11-04 05:34:38 +00:00
esengine 9937a1cb4a feat(desktop): delivered files are the board's one-column file cards, and the empty conversation is flat
A delivered file is one row in a single column: 16px file icon, 13px semibold name, 12px faint description, an Open pill and a 28px more button, 10px/12px padding in a 10px bordered card; the one open in the workbench takes the accent border. The two-column grid and the 22px badged icons are gone, and the more button keeps its "More actions: name" label.

The empty conversation drops the grid and glow it painted under the hero and sits on the plain surface, as both editor boards draw it.
2026-11-04 05:34:38 +00:00
esengine 3acc6dbaaf docs(release-notes): turn and execution card 2026-11-04 05:31:43 +00:00
esengine 6734594790 feat(desktop): the turn and its execution card follow the Chat-Reply and Think-Exec boards
Your message is a 15px overlay bubble at 14px/1.6 and at most 78% of the column; the reply head is a 20px accent R tile beside the 13px semibold name. The execution process is one card with a hair border and 10px radius: a 30px header carrying the running pill and the step count, then 28px rows of kind glyph, the board's verb (读取, 编辑, 运行命令…), the target in 12px mono, the reading, and the status mark at the right end. The raw tool id stays on the verb's title, where it is still one hover away.

The legacy !important rules on .activity-body, .activity-group and .call[data-call] > .c are deleted so the design layer can draw them; src/design/turn.css holds the replacements.
2026-11-04 05:31:43 +00:00
esengine e8c432a024 docs(release-notes): composer restyle 2026-11-04 05:26:10 +00:00
esengine 04ad0e3c25 feat(desktop): the composer follows the Chat-Composer board
One 15px box with the composer's only shadow; a top row that exists only while a turn runs or files have changed, holding the run pill and the changed-files pill; 14px/1.6 input with 10px/16px padding; a toolbar of 28px borderless pills in the 12px meta size; the model name in 13px semibold text beside its effort; 32px round send and stop, stop drawn as a danger ring. Under 590px of conversation the pickers tighten and under 420px the effort reading folds into the model button, as the narrow-window state shows. The status line keeps 11px faint labels with muted semibold values.

The legacy !important rules on .compose, the pickers and the attach buttons are deleted, since an important declaration in the legacy layer outranks every design-layer rule; the replacements live in src/design/composer.css.
2026-11-04 05:26:10 +00:00
esengine ca59c7cc00 docs(release-notes): tools entry reading 2026-11-04 05:15:56 +00:00
esengine 732d5f94d8 feat(desktop): the sidebar's tools entry names the session's approval mode
The Rail-Foot board labels each icon with its current reading; usage and remote already did, and the tools entry now reads "Tools & permissions · Ask" from the focused session's approval mode, through the same approvals table the composer uses.
2026-11-04 05:15:56 +00:00
esengine 4464970e1d docs(release-notes): window bar and sidebar foot 2026-11-04 05:14:37 +00:00
esengine fe55132d55 feat(desktop): the window bar, empty state and sidebar foot follow the editor board
The window bar is 48 high on the surface with a hair line under it; the breadcrumb is 13px and the four icon buttons are 28px, ordered find, device access, theme, workbench. The sidebar foot is the Rail-Foot board's single toolbar row: usage, files, tools, extensions, memory and remote as 28px icons whose names carry their reading, settings at the right end, and feedback as its own 30px row with the unread count. The account entry leaves the rail for settings, as the board says. The empty state's mark sits in a 44px accent tile above a 22px title.

The legacy .chrome, .railfoot, quick-nav, wallet, user-foot and hero rules are deleted rather than overridden; the replacements live in src/design/shell.css in the design layer.
2026-11-04 05:14:26 +00:00
esengine f4f32cc7d3 docs(release-notes): send-now button 2026-11-03 20:07:47 -08:00
esengine ff34193a55 feat(desktop): the send-now button on a queued line is the board's 24px pill
The legacy qnow rules are removed and the button takes its look from src/design/queue.css, with a test pinning its height, border, radius and surface.
2026-11-03 20:07:43 -08:00
esengine 94424bda54 docs(release-notes): shared sidebar row styles and bundled Inter 2026-11-03 19:59:59 -08:00
esengine 9c4393d30d refactor(desktop): project rows take their look from the shared rows stylesheet
Local and remote project rows carry the ds-proj class and the legacy .wsrow, .wsname, .wsacts, .rowswap, .wsadd and .wsmore rules are deleted. Rows are 30px with a chevron, folder, name and hover actions as in the board.
2026-11-03 19:58:11 -08:00
esengine b4c548b1a8 fix(desktop): serve the bundled Inter first and declare its weights as numbers
The --ui stack named a system Inter that is rarely installed, so Latin text fell through to the platform CJK font. The bundled Inter faces also declared font-weight through var(), which is invalid inside @font-face, so every weight resolved to the last face (SemiBold). The faces now declare 400, 500 and 600, and the stack starts with the bundled family.
2026-11-03 19:44:29 -08:00
esengine 8e203505d9 refactor(desktop): session rows take their look from one stylesheet shared with the design boards
Session and project row styling now lives in src/design/rows.css, the same file the design boards embed, and the legacy .sessrow, .sesstitle, .sessword and quick-action rules are deleted rather than overridden. Hover swaps the time for a more button and a pin sits right of the title, as in the board. Also repairs three tests left red by the earlier filter dropdown change.
2026-11-03 19:32:26 -08:00
esengine 5c97da241b refactor(styles): put legacy sheets in a cascade layer and design styles above it
Every stylesheet the app imports now sits in the legacy layer, and the measured settings and sidebar rules live in a design layer that wins by layer order instead of selector weight, so they no longer need :root[data-theme] prefixes. Rail pixels are identical before and after.
2026-11-03 19:18:25 -08:00
esengine 10709e6642 docs(release-notes): sidebar head height 2026-11-03 19:04:25 -08:00
esengine 1462713862 style(desktop): the sidebar head is 32 high with 12 above it and project groups sit flush, as designed; macOS keeps the title-bar height 2026-11-03 19:04:25 -08:00
esengine 8f7690f021 docs(release-notes): legacy tag removed 2026-11-03 19:01:33 -08:00
esengine 8b8ca36057 feat(desktop): session rows no longer carry the legacy tag, as the design allows one tag at most 2026-11-03 19:01:33 -08:00
esengine e7f13057ae docs(release-notes): session versions in menu 2026-11-03 19:00:05 -08:00
esengine d23cfeb216 feat(desktop): the earlier-versions chip is gone from the row; it is a menu entry that unfolds them, as designed 2026-11-03 19:00:05 -08:00
esengine 8bf1c73193 docs(release-notes): session rows designed states 2026-11-03 18:54:06 -08:00
esengine 962da251ec feat(desktop): session rows follow the design: left status dot, status word, date in the designed words, no indent, calm states 2026-11-03 18:54:06 -08:00
esengine cdd3d9add2 docs(release-notes): sidebar fold-all button 2026-11-03 18:48:07 -08:00
esengine b0a0adc2fe feat(desktop): collapse all projects is the icon button on the tab row, as drawn in the design 2026-11-03 18:48:07 -08:00
esengine 39d90df113 docs(release-notes): sidebar header menu moved 2026-11-03 18:46:04 -08:00
esengine d10bdfaad3 feat(desktop): batch select lives in the project row menu as designed; the project header has no more menu 2026-11-03 18:46:04 -08:00
esengine 1dcf6af8d3 docs(release-notes): sidebar tab row 2026-11-03 18:39:51 -08:00
esengine 032ae8f084 feat(desktop): the project header is the designed tab row: Projects/Recent segment, filter and add on one line 2026-11-03 18:39:51 -08:00
esengine 43598a1dbc docs(release-notes): sidebar brand head 2026-11-03 18:34:50 -08:00
esengine 3197b4c563 feat(desktop): the sidebar head is the designed one: logo and STUDIO badge aligned to the buttons, a collapse button, 264 default width 2026-11-03 18:34:50 -08:00
esengine e05823be3e docs(release-notes): sidebar head designed 2026-11-03 18:26:07 -08:00
esengine 594a2bd740 feat(desktop): the sidebar head follows the design: bordered new-session and search with their shortcuts, entries moved to the foot 2026-11-03 18:26:06 -08:00
esengine c338eb9a5b docs(release-notes): session rows designed size 2026-11-03 18:15:19 -08:00
esengine a51e003255 feat(desktop): session and project rows take the designed height and size, and local projects get a fold chevron 2026-11-03 18:15:19 -08:00
esengine 21ebb4770d docs(release-notes): host unreachable names the firewall 2026-11-03 17:56:20 -08:00
esengine 92a7ae08e2 fix(desktop): the shell checks the host is reachable before loading it and names the firewall when it is not 2026-11-03 17:56:20 -08:00
esengine 23102a7e8b docs(release-notes): designed font stack 2026-11-03 17:51:10 -08:00
esengine 46dd272fc4 feat(desktop): the interface font stack is the designed one: Inter, system CJK fonts, then the bundled faces 2026-11-03 17:51:10 -08:00
YHH bfdc7e2a1d Merge pull request #14355 from kevin9327/fix/gitignore-worktree-info-exclude
fix(gitignore): read info/exclude from the common dir when .git is a file
2026-11-03 17:45:27 -08:00
esengine 5f874dc1d4 docs(release-notes): settings left column icons and font 2026-11-03 17:41:42 -08:00
esengine e2c1587e77 feat(desktop): settings left column takes the designed icons, 240 width, row type and the designed font stack 2026-11-03 17:41:42 -08:00
esengine 9d18948916 chore(repolint): a release-note item is no longer held to a column limit, only to naming its change 2026-11-03 17:27:21 -08:00
esengine a1b4dd382b docs(release-notes): shorten two settings notes 2026-11-03 17:21:34 -08:00
esengine 2e57b9bf90 docs(release-notes): settings row vocabulary 2026-11-03 17:19:10 -08:00
esengine a5d5e76531 feat(desktop): the Common page rows use the designed row vocabulary: segmented choices, dropdown, switch, one-line notes 2026-11-03 17:19:10 -08:00
esengine fb7ef5b50b docs(release-notes): settings measured style 2026-11-03 17:16:00 -08:00
esengine 8ee94c1039 feat(desktop): settings shell, cards and rows take the measured values from the design 2026-11-03 17:16:00 -08:00
esengine 24e5559fca docs(release-notes): settings common cards 2026-11-03 17:11:19 -08:00
esengine 765ac8aa23 feat(desktop): the Common settings page is two cards of one-line rows, with language as a dropdown, as designed 2026-11-03 17:11:19 -08:00
esengine 960d437962 docs(release-notes): settings title line and agent cards 2026-11-03 17:09:44 -08:00
esengine 7574157761 feat(desktop): the page title reads on one line, and Agent behaviour groups plans, questions and collaboration into cards 2026-11-03 17:09:44 -08:00
esengine d3cd11eecd docs(release-notes): settings rows and data cards 2026-11-03 17:06:52 -08:00
esengine 7dd377fe47 feat(desktop): settings rows read as one line with the control on the right, and Data is three section cards 2026-11-03 17:06:52 -08:00
esengine 3323381609 docs(release-notes): settings left column 2026-11-03 17:04:35 -08:00
esengine 43e78eda03 feat(desktop): settings puts the title and search in the left column and shows an item count on each category, as designed 2026-11-03 17:04:35 -08:00
esengine 5dc2f28c9e docs(release-notes): settings section cards 2026-11-03 17:01:25 -08:00
esengine ff391889b4 feat(desktop): settings sections are one card with a row per item, the control on the right, as designed 2026-11-03 17:01:25 -08:00
esengine a8af9ee097 docs(release-notes): permissions sub-headings 2026-11-03 16:57:16 -08:00
esengine 84ebeedc06 feat(desktop): the permissions page reads as one page with sub-headings; plan approval moves to Agent behaviour and file cards to Conversation display 2026-11-03 16:57:16 -08:00
esengine 10449cbd87 docs(release-notes): settings items moved 2026-11-03 16:56:18 -08:00
esengine eae50a7a58 feat(desktop): settings moves trace export, backups, session location, auto-archive and 1.x sessions to the pages the design files them under 2026-11-03 16:56:18 -08:00
esengine e0eba2e4a7 docs(release-notes): settings eleven pages 2026-11-03 16:55:27 -08:00
esengine d3b6daef14 feat(desktop): settings splits appearance into Appearance, Conversation display and Controls and performance 2026-11-03 16:55:27 -08:00
esengine f832f84a39 docs(release-notes): settings nav order 2026-11-03 16:53:37 -08:00
esengine 0767a73bd3 feat(desktop): settings nav follows the design order flat, and each page says how many items it holds and what it is for 2026-11-03 16:53:37 -08:00
esengine da62677471 chore(desktop): English names for the Common settings page 2026-11-03 16:45:37 -08:00
esengine 522f7d1f01 docs(release-notes): settings common page 2026-11-03 16:42:44 -08:00
esengine 862f8865c4 feat(desktop): settings opens on a Common page holding the six most-changed items 2026-11-03 16:42:44 -08:00
esengine 86dcc1b9b8 docs(release-notes): sidebar remote online 2026-11-03 16:40:52 -08:00
esengine 0de5fe17c4 feat(desktop): the sidebar's remote entry says how many machines are online 2026-11-03 16:40:52 -08:00
esengine 068dfffaa1 docs(release-notes): icon rail off by default 2026-11-03 16:40:09 -08:00
esengine e461d48992 feat(desktop): the icon column is off unless chosen; the sidebar carries its entries 2026-11-03 16:40:09 -08:00
esengine 52c86b5e36 docs(release-notes): workbench recent files 2026-11-03 16:31:49 -08:00
esengine c5483809c6 feat(desktop): the workbench start page lists recently opened files 2026-11-03 16:31:49 -08:00
esengine 34cad82075 docs(release-notes): settings search path 2026-11-03 16:31:12 -08:00
esengine 8def264b95 feat(desktop): settings search shows page › sub-section and finds a folded page by its old name 2026-11-03 16:31:12 -08:00
esengine 9c89690c25 docs(release-notes): settings automation in extensions 2026-11-03 16:30:38 -08:00
esengine a2289aa88a feat(desktop): settings folds automation into the Extensions page 2026-11-03 16:30:38 -08:00
esengine 47a4010016 docs(release-notes): settings agent behaviour page 2026-11-03 16:30:21 -08:00
esengine d0b1bc8fd2 feat(desktop): settings folds personas and peer messages into the Agent behaviour page 2026-11-03 16:30:21 -08:00
esengine 3483500174 docs(release-notes): settings data and account pages 2026-11-03 16:29:55 -08:00
esengine 111debd2b7 feat(desktop): settings folds memory, usage and storage into Data, and account and version into one page 2026-11-03 16:29:48 -08:00
esengine bfb4edc753 docs(release-notes): settings connections page 2026-11-03 16:29:01 -08:00
esengine 10d87b83a3 feat(desktop): settings folds remote machines and phone access into the Connections page 2026-11-03 16:29:01 -08:00
esengine 628a5acaa8 docs(release-notes): settings model page 2026-11-03 16:28:22 -08:00
esengine 80353df7ef feat(desktop): settings folds the model-service page into one Model page 2026-11-03 16:28:22 -08:00
esengine 810d6c33d8 docs(release-notes): name the commit behind each interface change 2026-11-03 16:24:21 -08:00
esengine ed18015c1d feat(desktop): single-line question options are a normal row tall 2026-11-03 16:23:43 -08:00
esengine 9d9926e48d refactor(desktop): drop shadows read one shade variable 2026-11-03 16:23:43 -08:00
esengine df2ae96613 feat(desktop): workbench file icons are tinted by file kind 2026-11-03 16:23:43 -08:00
esengine 498d2af00b feat(desktop): background jobs can be stopped all at once after one confirmation 2026-11-03 16:23:43 -08:00
esengine 751974427d feat(desktop): the background-job reading says running, failed or ended 2026-11-03 16:23:43 -08:00
esengine 2b6405327d feat(desktop): the palette opens with a recently-used group 2026-11-03 16:23:43 -08:00
esengine aebed7fb56 feat(desktop): session search highlights the matching part of the title 2026-11-03 16:23:43 -08:00
esengine cbaaeb98f8 feat(desktop): a pending permission card is outlined, its button row loses the divider and grey strip 2026-11-03 16:23:43 -08:00
esengine 6eda696596 feat(desktop): folded thought reads amount first, then whole seconds 2026-11-03 16:23:43 -08:00
esengine f2b44860c4 feat(desktop): a failed sub-agent reads Failed with its reason, not Interrupted 2026-11-03 16:23:43 -08:00
esengine ce6c6d144f feat(desktop): sub-agent footer reading says running count, then the total once finished 2026-11-03 16:23:43 -08:00
esengine 14d1d36915 test(studio): the recent-sessions flow reads the filter dropdown
The test reached for the old segmented container and its pressed buttons. It
opens the dropdown and reads the button's label instead.
2026-11-03 16:23:43 -08:00
esengine 8b1218dd48 docs(release-notes): session filter dropdown 2026-11-03 16:23:43 -08:00
esengine b4386d746f feat(studio): the session filter is a dropdown with counts and two new filters
Four tiny segmented buttons sat in the rail, with no room for a count and no
way to ask for what was waiting on the person or what was unread.

One button now reads the current filter and opens a list of All, Running,
Needs you, Unread, Pinned and Archived, each with its count where it is known
(Archived is paged, so it shows none). Anything but All shows a clear mark
beside the button. Needs you lists conversations whose turn is waiting on an
answer; Unread lists the ones with a reply not yet seen. The rows keep the
data-action and value the old buttons had.

Blast radius: Sidebar, a new SessionFilter, the scope rule, Workspaces' call
into it, the rail's stylesheet, and two older tests that now open the menu.

Why this layer: the scope rule owns what each filter lists.
2026-11-03 16:23:43 -08:00
esengine f066c3ad83 docs(release-notes): settings page title 2026-11-03 16:23:43 -08:00
esengine 66cf4d8970 feat(studio): each settings page opens with its group and name
Settings pages started straight at the first card, so after a scroll or a
search jump nothing in the page said which page it was; the left list was the
only landmark. A request in the settings-redesign thread asked for a compact
page title or breadcrumb.

Each page now opens with a one-line title: the group it belongs to in the
light step, then the page name as the single h2.

Blast radius: SettingsTitle in SettingsNav, one line in Settings, four rules.

Why this layer: the nav already owns the group and page names.
2026-11-03 16:23:43 -08:00
esengine c78941743c docs(release-notes): pending card tag 2026-11-03 16:23:43 -08:00
esengine c1c3419343 feat(studio): every card waiting on the person says so with the same tag
Only the permission card said it was waiting ("Waiting"); a question or an
extension's request sat in the transcript looking like anything else, and the
tags counted questions instead.

The permission, question and extension-request cards now carry one tag,
"Needs you", as an outlined accent pill, which turns to "Handled" once
answered.

Blast radius: three cards, one string, one rule.

Why this layer: each card names its own state; one style draws them alike.
2026-11-03 16:23:43 -08:00
esengine c6fb0eb95b test: skip symlink fixtures where the platform cannot build symlinks
Cause: ten fixtures in serve, ext/plugin and platform/repair built a symlink
with os.Symlink and failed the test on error. A non-elevated Windows run
without Developer Mode returns ERROR_PRIVILEGE_NOT_HELD there, so ten tests
failed and the whole serve suite went red, though CI's elevated runners never
saw it.

Fix: skip those fixtures with the underlying error as the reason, the pattern
the boot package already uses; the refusals they pin down still run wherever
symlinks can be built.

Blast radius: ten test sites in five files, no production code.

Neighbouring behaviours tested: the same tests run unchanged where symlinks
work (ext/plugin, platform/repair, serve), and the packages vet for windows.

Why this layer: the fixture owns whether it can build its own precondition.

Fixes #14969
2026-11-03 15:26:16 -08:00
esengine f941eec2e1 docs(release-notes): session batch bar 2026-11-03 08:55:41 -08:00
esengine 5b51c01280 feat(studio): batch selection is a bar fixed to the foot of the session list
Selecting sessions opened a small bordered box in the middle of the list, its
buttons 5px-padded to no standard height.

It is now a bar fixed to the bottom of the list, on the float surface with its
shadow and a top edge, entering with the panel timing, and its buttons are the
small 24px pills.

Blast radius: the session-batch stylesheet.

Why this layer: these rules are the bar's only definition.
2026-11-03 08:55:41 -08:00
esengine ec7a7e29ee docs(release-notes): body 14px 2026-11-03 08:55:22 -08:00
esengine c84e1682e7 feat(studio): the conversation body reads at 14px by default
The spec sets the reply body at 14px. The reading dial defaulted to 13px in
Chinese and 12.5px in English, and its steps were built around that, and a few
execution-row sizes were multiples of it that landed between the six steps.

The standard step is now 14px in both languages (small 12; large and larger
16.5 and 19 in Chinese, 16 and 18 in English, chosen clear of the old retired
values the saved setting still migrates). The small multiples in the execution
rows name the meta and micro steps instead.

Blast radius: look.ts steps and default, the first-frame fallback, four rules.

Why this layer: look.ts owns the reading dial; the stylesheets only read it.
2026-11-03 08:55:22 -08:00
esengine be3b0cac19 docs(release-notes): weights three steps 2026-11-03 08:53:36 -08:00
esengine 5f78e0b21e feat(studio): every font weight comes from the three weight tokens
The stylesheets wrote about 230 weights as bare numbers (300, 400, 450, 500,
550, 600, 620, 650), so text carried weights the spec does not have and the
weight setting could not move them together.

Each now names the nearest of --w-reg, --w-med and --w-semi (up to 550,
up to 650, above), and a test keeps it so. Where the settings page re-points
--w-med to --w-semi the same rule holds.

Blast radius: 12 stylesheets and the theme test that pinned two old spellings;
450 and 550 text moves to the regular and medium steps.

Why this layer: the tokens own the weights; rules only name them.
2026-11-03 08:53:36 -08:00
esengine cd01aefd1d docs(release-notes): type sizes six steps 2026-11-03 08:52:54 -08:00
esengine f360221b06 feat(studio): every text size comes from the six size tokens
The spec has six type sizes, 11 / 12 / 13 / 14 / 17 / 22. The stylesheets
still wrote about 300 sizes as a bare calc of pixels, among them 117 at 9 and
10px and some at 7.5, 10.5 and 11.5, so text sat between the steps and a size
setting could not keep them in proportion.

Each now names the nearest token (9 to 11 up to micro, 11.5 to 12.5 meta, 13
ui, 14 and 15 lead, 17 to 19 title, 20 to 23 display); 16px on coarse
pointers and the display figures of 25px and up are left. A test keeps it so.

Blast radius: 17 stylesheets; small text grows by one or two pixels.

Why this layer: the tokens own the sizes; rules only name them.
2026-11-03 08:52:54 -08:00
esengine c3e3979414 docs(release-notes): jobs time and stop 2026-11-03 08:48:25 -08:00
esengine 11169ceb3b feat(studio): background tasks read their time in minutes and stop on a second press
The task list wrote "741s" and stopped a task on one press of a small square.
It now reads "12 min 21 s", and the stop control turns into "Stop? Confirm"
for three seconds, so a stray press cannot end a running task.

Blast radius: Jobs, a duration helper with its own test, one action id, the
strings and one rule; the deck-jobs test presses twice.

Why this layer: Jobs owns how a task row reads and stops.
2026-11-03 08:48:25 -08:00
esengine 5834759fb1 docs(release-notes): small buttons 24 2026-11-03 08:47:07 -08:00
esengine c3563fc225 feat(studio): small buttons are 24px: stage all, commit, the changed pill, model filter chips
An audit of the live interface found small actions at 20, 23 and 26px where
the spec has one small size of 24. Stage all and Commit in the explorer
header (20), the changed-files pill (26) and the model filter chips (23) now
draw at 24, with the spec's meta size for their text.

Blast radius: four stylesheet rules.

Why this layer: each rule was the control's own size.
2026-11-03 08:47:02 -08:00
esengine 0049f6d880 docs(release-notes): palette frame 2026-11-03 08:45:58 -08:00
esengine 0a8829fbb8 feat(studio): the palette opens 600 wide, 12% from the top, over a 16% dim
The palette was 560 wide at 14% over a blurred wash, drawn with the card
timing. It now follows the board: 600 wide, 12% from the top, a plain 16% dim
behind it, the large corner, and the panel timing on entry.

Blast radius: the two palette rules in studio.css.

Why this layer: these rules are the palette's only frame.
2026-11-03 08:45:58 -08:00
esengine 93aa8daf61 docs(release-notes): palette scopes 2026-11-03 08:45:44 -08:00
esengine f46e8a274b feat(studio): the palette narrows by scope, ranks a best match and finds settings
The palette searched quick actions and eight conversations only, always
mixed, with no way to look at one kind.

A leading > # or , now narrows to actions, conversations or settings (Tab
cycles, Backspace on an empty field drops it, and the scope shows as a tag in
the field). A search puts the single best match alone first, then conversations,
settings and actions, five of each at most. Settings come from the existing
settings index and open at their block. The footer prints the scope keys.

Not here: file search (there is no file index to ask) and the search-all-
conversations fallback, which needs the sidebar's content search wired in.

Blast radius: Palette, the Sidebar that hosts it, three strings and one rule.

Why this layer: Palette owns how results are grouped; the sidebar only hands
it the settings index.
2026-11-03 08:45:44 -08:00
esengine e3bfdf96c4 docs(release-notes): rail edits longer 2026-11-03 08:43:33 -08:00
esengine b14f1ea829 feat(studio): a message that changed files draws a longer line on the rail
Every line on the message rail was the same length, so a message that changed
files looked like any other. Those lines are now longer.

Blast radius: one attribute in Rail and one rule in app.css.

Why this layer: Rail knows each mark's file count; the stylesheet draws it.
2026-11-03 08:43:33 -08:00
esengine d1cc623de8 docs(release-notes): rail card words 2026-11-03 08:43:15 -08:00
esengine d7e6e0129b feat(studio): the message rail's card reads "Your 6th · changed 3 files" with a hint
The hover card spaced numbers from their words ("your 6th message") and gave
no hint what a click or a right-click does.

The header now reads without those spaces, names how many files the message
changed beside its number, and a line under the text says click jumps and
right-click stars.

Blast radius: the card in Rail, one rule, four strings.

Why this layer: Rail owns its card.
2026-11-03 08:43:15 -08:00
esengine 87550bbda1 refactor(studio): the row's right-hand state or time is its own component
Workspaces.tsx reached 802 lines, over the 800-line ceiling. The choice
between a state word and the time moves next to SessionAge, which already
owns the time.
2026-11-03 08:40:47 -08:00
esengine ff09e4edc2 docs(release-notes): browser blank page 2026-11-03 08:38:38 -08:00
esengine 0b21cafee4 feat(studio): the browser's blank page speaks Chinese and says what the blue dot means
The blank page was titled in English and did not say how to tell the model is
operating a page.

It is now titled "Built-in browser", says to enter an address and that a blue
dot on the tab shows the model at work, and that a site that refuses
embedding opens from the toolbar.

Blast radius: the blank-page markup in BrowserPanel and its English strings.

Why this layer: BrowserPanel owns its own blank page.
2026-11-03 08:38:38 -08:00
esengine 14804c7c7c docs(release-notes): launcher descriptions 2026-11-03 08:38:18 -08:00
esengine dddcf2b3d5 feat(studio): the workbench's new tab says what each entry opens
The new tab was three bare buttons, so a first-time visitor had to guess what
"Files" or "Changes" would open.

It now has a heading and a line under each entry saying what it opens. The
buttons keep their names and actions.

Blast radius: WorkbenchLauncher, its stylesheet and four strings.

Why this layer: the launcher owns its own words.
2026-11-03 08:38:18 -08:00
esengine a6955a49fa docs(release-notes): activity earlier fold 2026-11-03 08:37:30 -08:00
esengine 42e8d2e5ae feat(studio): a long run of work folds its earlier steps into one line
A run with eighteen steps listed all eighteen, so the few that mattered were
buried. Past six, the earlier steps now fold into one line that says how many
and of what kind ("14 earlier steps: read 9 · search 3 · edit 2"); the last six
stay listed and the line opens the rest. A failed or still-running step is
never folded, however old.

Blast radius: the activity group in Transcript, a new pure helper, one action
id, strings and one rule.

Why this layer: the group owns what it lists; the helper only decides the cut.
2026-11-03 08:37:30 -08:00
esengine e79f2b8168 docs(release-notes): workbench folder tabs 2026-11-03 08:33:58 -08:00
esengine 71212621b4 feat(studio): workbench tabs are folder tabs joined to their content
Tabs were small outlined pills floating in a strip, so the current one was
hard to tell and nothing tied it to the page below.

All tabs now share one height with top-rounded corners; the current tab takes
the content's colour and edge so it joins the page, and reads heavier. The
strip sits on the page colour so the joined tab shows.

Blast radius: the workbench tab rules in studio.css.

Why this layer: these rules are the tabs' only definition.
2026-11-03 08:33:58 -08:00
esengine df24cd4ece docs(release-notes): recent sessions keys 2026-11-03 08:32:21 -08:00
esengine c97d7b6afa feat(studio): recent sessions show what is running and can be walked by keyboard
The list said nothing about which conversation was running and had to be
tabbed through row by row. A running or waiting row now reads "Running" or
"Needs you" where its time sat, Up and Down walk the rows (and back to the
search field), and a line under the list says so.

Blast radius: RecentSessions, the Sidebar prop that feeds it, two strings and
one rule.

Why this layer: the dialog owns its rows; the sidebar already holds the runs.
2026-11-03 08:32:20 -08:00
esengine a30f26dafa docs(release-notes): changed pill right 2026-11-03 08:31:39 -08:00
esengine 1a649b2ffa feat(studio): the changed-files pill sits at the right of the input box's top row
The pill had a row of its own, centred under the run badge. The board puts it
on the badge's row, at the right edge.

Blast radius: one rule in changedpill.css.

Why this layer: the row is the pill's own container.
2026-11-03 08:31:39 -08:00
esengine 5326f02ccb docs(release-notes): connect note 2026-11-03 08:30:40 -08:00
esengine 97c3ebbbf2 feat(studio): the not-connected note says nothing can be sent, with a warning icon
The note above the input read "No model service connected yet" with a bare
link, which did not say what it meant for what the person had typed.

It now leads with a warning icon and says "no model service is connected, so
nothing can be sent" (or the same for a missing API key). The typed text is
kept as before.

Blast radius: ConnectNote, one rule in app.css, two strings, one test.

Why this layer: ConnectNote owns this note's words.
2026-11-03 08:30:40 -08:00
esengine 6b3f2cab88 docs(release-notes): weights and input size 2026-11-03 08:30:08 -08:00
esengine 7f7f64e7fb feat(studio): weights are 500/600/700 and the input box text is 14px
The design spec sets three weights, 500 / 600 / 700, and the input box at the
lead size. English held 400 / 500 / 600 and the box followed the transcript
reading dial, so the two disagreed with the board.

The weight tokens now carry the spec on every language (light still steps
down, heavy steps up), and the input box draws its text at --fs-lead on
every pointer. The reading dial keeps the transcript.

Blast radius: tokens.css weights, two input-box rules, TOKENS.md, one theme test.

Why this layer: the tokens own the weights and the size.
2026-11-03 08:30:08 -08:00
esengine 6af297b927 docs(release-notes): session row word 2026-11-03 08:29:04 -08:00
esengine a0ce00b5f7 feat(studio): a conversation row names its state in place of the time
The row's dot said a conversation was running or waiting, but only colour
told which. A running row now reads "Running" and a waiting one "Needs you"
where the time sat; every other row keeps its time or unread mark.

Blast radius: the row in Workspaces, two strings and three rules in treerows.css.

Why this layer: the row owns what sits on its right edge.
2026-11-03 08:29:04 -08:00
esengine 5cbedc4916 docs(release-notes): meter icons 2026-11-03 08:25:34 -08:00
esengine 2db788599f feat(studio): the status readings lead with an icon and set value against label
The bottom readings were bare words and numbers. Cache and the session cost
now lead with a 12px icon, the number reads in the text colour and its label
in the light one, and the warm context level keeps its number warm.

Blast radius: the MeterRail markup for two readings, two icons, studio.css.

Why this layer: MeterRail owns the readings; the stylesheet names the colours.
2026-11-03 08:25:34 -08:00
esengine 5050eef812 docs(release-notes): context reading warm 2026-11-03 08:21:56 -08:00
esengine 20903c3470 feat(studio): the context reading turns warm near the limit
The context ring and percentage looked the same at 19% and at 90%, so the
limit arrived without warning. From 80% the reading and its ring now turn the
warning colour; nothing opens or interrupts.

Blast radius: the context button in MeterRail and two studio.css rules.

Why this layer: MeterRail owns the reading and states the level; the
stylesheet only names the colour.
2026-11-03 08:21:56 -08:00
esengine 782ad209bd docs(release-notes): composer running state 2026-11-03 08:19:34 -08:00
esengine efdbf9788c feat(studio): the input box shows running, steering and the model's vendor as drawn
While a turn runs the stop control was a dark labelled pill, the send control
appeared only once something was typed, the placeholder kept saying "describe
a task", and the model button carried no mark of who serves it.

Stop is now a 32px red-ringed circle and send stays beside it (grey until
there is text). With text typed during a run the send control becomes a dark
"Steer" or "Queue" pill. The placeholder reads "You can keep typing while it
runs". The model button leads with the vendor's mark. Both buttons carry an
aria-label because their text is hidden in the circle form.

Blast radius: Composer, its two button rules in studio.css, one app.css rule
(a disabled send is no longer hidden while running), one string.

Why this layer: the composer owns what the send and stop controls say.
2026-11-03 08:19:34 -08:00
esengine d15b1180cf docs(release-notes): model and effort in one button 2026-11-03 08:15:39 -08:00
esengine 5d560c2879 feat(studio): model and reasoning effort share one button and one panel
The model and the reasoning effort were two buttons with two menus on either
side of the input box, though effort is a capability of the model and the
level was written twice ("Auto" in two languages).

One button now reads the model and the effort once, and opens one panel: the
models on the left as before, the efforts of the current model beside them.
Picking an effort applies it and closes the panel; picking a model applies it
and leaves the panel open so an effort can follow. A model that reports no
levels still points at where they are declared. Picker gains an aside column
with its own close handle, a stay-open rule, and Left/Right to cross columns.

Blast radius: Picker/MenuRow, Composer, the effort helpers and the composer
menu, narrow and studio stylesheets (the old effort button's rules are gone).

Neighbouring behaviours: tests that opened the old effort button now use the
merged one; the new panel has its own interaction tests.

Why this layer: Picker owns menu behaviour; the composer only says which two
lists go in it.
2026-11-03 08:15:39 -08:00
esengine bbef48cc65 docs(release-notes): composer controls height 2026-11-03 08:04:15 -08:00
esengine 52534f832b feat(studio): the input box's toolbar controls share one 28px height
The attach, side and refine buttons were 29px, the branch chip 29px and the
mode and permission chips 32px, so the row of controls under the input
stepped up and down. The board sets every selector at 28px with the send
button at 32px.

Blast radius: the studio.css rules for the toolbar controls; the narrow-window
icon-only mode picker follows (30px to 28px).

Neighbouring behaviours tested: the style suites and the full desktop suite;
the box was rendered in both schemes.

Why this layer: the sizes live in these rules and a test now pins them.
2026-11-03 08:04:15 -08:00
esengine d5ad9ba17f docs(release-notes): spec corners, type and motion 2026-11-03 07:49:23 -08:00
esengine 0280ffb399 feat(studio): corners, type size and motion timing follow the design spec
The design spec steps corners 5 / 7 / 10 / 15 px, sets the lead size at 14px
and times a line, a card and a panel 120 / 180 / 260 ms. The tokens held
4 / 6 / 9 / 12, 15px and 140 / 220 / 340 ms, and 199 corners and 34 timings in
the stylesheets were written as bare numbers, so moving a token reached none
of them.

The tokens now carry the spec's values, every bare corner of 3px and up draws
from the nearest radius token (so a pack's corner recipe reaches it too), and
every bare 100-150ms timing uses --t-line. Two ratchet tests keep it so.
Weights are left alone: the language and weight settings already own them.

Blast radius: all stylesheets; corners grow by about a pixel and 15px text
becomes 14px where --fs-lead is used, motion is a little quicker.

Neighbouring behaviours tested: the style suites and the full desktop suite;
chat and workbench were rendered before and after in both schemes.

Why this layer: tokens own the value, stylesheets only name it.
2026-11-03 07:49:23 -08:00
esengine 2948ec98ec docs(release-notes): recent sessions groups 2026-11-03 07:39:48 -08:00
esengine d0f2be43a0 feat(studio): recent sessions are grouped by day with a compact filter row
The list ran together from the first row to the last, the search box had no
icon, and the environment choice took a labelled row of its own.

Rows now sit under "today" and "earlier" headings, the search box carries its
icon and shares one row with a compact environment select (still named
"environment" for assistive tech), and the empty state and paging are
unchanged. The board's running-state dot and arrow-key hint are not built:
this list has no running state and no arrow-key navigation to back them.

Blast radius: RecentSessions and treerows.css; picking, paging, retry and the
unsupported-environment message are untouched.

Neighbouring behaviours tested: paging with retry, locating a collapsed
project, environment switching, plus the full desktop suite.

Why this layer: the dialog owns how its own rows are grouped.
2026-11-03 07:39:48 -08:00
esengine 1bc4c58aa5 docs(release-notes): move-session dialog 2026-11-03 07:31:40 -08:00
esengine 38143faff0 feat(studio): move-session dialog shows what travels beside what stays
The dialog asked for the target in a drop-down and put what travels and what
does not into two run-on paragraphs, so the consequence was read last.

It now lists the target projects as a radio list with their paths, sets what
travels and what stays behind in two short columns, and with no other project
offers to add one right there. Footer buttons use the shared medium buttons.

Blast radius: MoveSessionDialog, the hook that hosts it, one action id and
the English strings; the other pick dialogs keep their own button styles.

Neighbouring behaviours tested: move succeeds, picks the chosen project, is
refused with the kernel's reason, partial move, empty state with Add project,
focus on open, plus the full desktop suite.

Why this layer: the dialog owns its own content and the host passes the
add-project action through the hook rather than the dialog reaching for it.
2026-11-03 07:31:40 -08:00
esengine cb465b4109 docs(release-notes): session menu groups 2026-11-03 07:20:21 -08:00
esengine 7381820724 feat(studio): session menu is grouped by what the action does
The menu was one flat list: close and branch sat between rename and archive,
export appeared twice, and why an item was unavailable lived in a tooltip.

It now has five groups (position, name, content, destination, ending), one
export entry with Markdown and JSON as choices, pause first while the
conversation runs, and the reason a close or move waits written under the
item and tied to it with aria-describedby. The menu is 260px wide.

Blast radius: SessionMenu and its styles; every action keeps its data-action
and accessible name, so the sidebar, sync, move and close suites are untouched.

Neighbouring behaviours tested: new group-order, export, running-state and
spaced-path tests, plus the full desktop suite.

Why this layer: SessionMenu owns the order and wording of the row actions.
2026-11-03 07:20:21 -08:00
esengine 7fae97de84 docs(release-notes): corner notices share one shape 2026-11-03 07:08:24 -08:00
esengine 419b65dbca feat(studio): corner notices share one shape
Failures, notes, offers, the update reminder and the size question were five
hand-made boxes of different widths, with the failure told apart only by red
text and its actions drawn as underlined links.

They now render through one ToastRow: a fixed 380px column, a leading icon
that tells a failure from a note, and the shared small pill buttons for the
actions. The size question stacks its choices under the text.

Blast radius: ToastStack, UpdateReady, SizeAsk and toasts.css; behaviour
(stay until dismissed, at most three, report, restart, zoom) is unchanged.

Neighbouring behaviours tested: the existing toast, update-ready, versions and
appearance suites plus the full desktop suite.

Why this layer: one component owns the notice shape so a new kind of notice
cannot be drawn a sixth way.
2026-11-03 07:05:16 -08:00
esengine 47fb2f3ac2 fix(inbox): read and write the queue schema 1.x leaves behind
Cause: Studio's inbox is schema 2 while 1.x writes schema 3, so a session
that had been used in 1.x opened as "written by a newer version" and its
queued messages could be neither sent nor deleted.

Fix: accept schema 3 and keep its fields (fingerprint, attachment identities,
image inputs and source refs) through every rewrite so nothing 1.x wrote is
dropped. This build cannot read 1.x image inputs, so an entry carrying them is
held with the foreign_images block code on both the run and steer paths
instead of being sent without the images.

Blast radius: state/sessioninbox (envelope, request hash, images flag), the
two dispatch paths in session/control, and the block-reason tables in the
desktop and terminal frontends.

Neighbouring behaviours tested: queue tests of the inbox, control, tui, serve
and i18n packages, plus the desktop queue and translation tests.

Why this layer: the envelope owns what is persisted and the shared
applyInboxReferences owns whether an entry may run.
2026-11-03 06:40:40 -08:00
esengine 27cc9f4191 docs(release-notes): note the free-text box choosing Other 2026-11-03 04:30:06 -08:00
esengine 3933dbed40 fix(ask): clicking into the free-text box chooses Other
The box under a question's options only switched to Other once
something was typed, so a click into it left the radio empty and
the card read as if nothing were chosen. Focusing the empty box now
selects Other; a single pick's note box keeps that pick, and
multi-choice picks stay alongside.
2026-11-03 04:29:56 -08:00
esengine a377a43652 docs(release-notes): note the unified settings fields 2026-11-02 23:29:06 -08:00
esengine 0e1dff5b5a fix(desktop): give every settings text field the spec box
kit.css draws the text inputs, textareas and unit fields inside the settings
pane: 32px high, 7px corners, 13px text, one focus ring and one error ring;
the per-model rows of model services use the 24px size. The unit field's box
is the outer span, its input stays borderless inside it. The rules that drew
these fields per page (session limits, providers, network, remote, backup,
appearance, peers, presets, advanced fields) keep only widths and the mono
family for addresses and keys. Two label-span rules in model services no
longer reach the unit field. field.test.ts holds those page rules to it.
2026-11-02 23:29:04 -08:00
esengine 5d73c8e205 docs(release-notes): point the drop-down note at the landed commit 2026-11-02 23:06:19 -08:00
esengine 22f02727f6 docs(release-notes): note the unified drop-downs 2026-11-02 23:03:48 -08:00
esengine e3e06303f5 fix(desktop): give every drop-down the spec look
kit.css owns single-choice selects: 32px high, 7px corners, 13px text and one
12px arrow set in 10px from the right edge, with the same hover, focus ring and
disabled look everywhere; data-size="sm" is the 24px variant for list rows
(permission rules, the phone-access address). The page rules that re-set the
native look for model roles, providers, permissions, network, remote, phone
access, memory editing, presets and the recent-machine picker keep only their
widths. select.test.ts holds every other stylesheet to that: none may set a
select's size, corners, padding or arrow.
2026-11-02 23:03:48 -08:00
esengine fd6fa05b19 test(history): assert a complete index only once the projection has settled
CI's Windows full suite saw the index answer State=ready with Indexed=Total=1
while a rescan was still pending, so SearchIndexProgress honestly reported
building and the test, which had only waited for the hit to appear, failed.
The test now retries until the projection reports every source indexed and
nothing pending, fails at once on any unreadable-source signal, and only then
asserts that no incomplete-index signal is raised.

Not reproduced locally: the base passed 20x on macOS, 90x under six concurrent
processes on Windows, and a full package run on Windows.
2026-11-02 22:57:54 -08:00
esengine bbdfaebdf3 docs(release-notes): note the unified button sizes 2026-11-02 22:28:07 -08:00
esengine 3d6e532b0e fix(desktop): give every button the spec sizes
Small buttons are 24px pills, medium ones 32px with the 10px corner, icon
buttons 28px squares with the 7px corner. kit.css carries the shared rules;
the page-scoped rules that re-set height, radius, font size and weight for
the title bar, the project tree, reply actions, model services, remote,
appearance and automation now use the same values. A browser sweep of the
main window and all sixteen settings pages measures 324 buttons, 0 off spec
(105 before).
2026-11-02 22:27:48 -08:00
YHH f06c3efca5 Merge pull request #14927 from lancy69/fix/14925-selection-menu-font
fix(studio): normalize reply selection menu tracking
2026-11-02 21:54:35 -08:00
YHH 40b9b8f211 Merge pull request #14947 from chuiwenwei/fix/history-search-does-not-hide-a-source
fix(history): a search says when the index could not read a source
2026-11-02 21:37:39 -08:00
YHH 055b60bc0c Merge pull request #14948 from chuiwenwei/fix/acp-lease-refusal-carries-a-code
fix(acp): a lease refusal says which refusal it is
2026-11-02 21:16:57 -08:00
YHH 1fd91e8f4a Merge pull request #14938 from lancy69/fix/14931-version-download-overlap
fix(desktop): prevent version download text overlap
2026-11-02 20:37:50 -08:00
esengine 3e9e807132 ci: drop the empty boot entry the root token budget step still required
The step runs only ./internal/runtime/agent, but its jq still required a
passing package event for internal/assembly/boot after the task budget
tests there were removed, so the step failed on every pull request.
2026-11-02 19:56:34 -08:00
Lancy 29c1e04267 fix(studio): normalize reply selection menu tracking 2026-11-03 03:19:14 +00:00
esengine 715a9f11fb fix(desktop): translate the memory page's remaining labels and list its demo data
The memory page drew 参考, 用过, 生效, 范围, 低, 高 and 所有项目 without
an English entry, and its new demo facts were not on the English-window
guard's fixture list, so browser guards failed for every pull request.
2026-11-02 18:56:49 -08:00
esengine d88acbe67d feat(desktop): a three-column memory page with galaxies, retention and review
The memory page lists facts by type and galaxy with a search box,
shows each fact's retention, uses and last recall, lets a newly
learned fact be confirmed or archived from the list, and edits its
type, galaxy and importance.
2026-11-02 18:14:18 -08:00
esengine d0289a4d0f feat(memory): expose galaxy, strength and review state to the memory page
The memory list now carries each fact's galaxy, importance, retention
state, use count, days left before it turns fuzzy, whether it decays,
and whether it is new (written in the last 14 days and not yet reviewed).
POST /memory/confirm reinforces a fact and marks it reviewed; a save from
the page also marks it reviewed and may move its galaxy, importance and
type. The review mark lives in the usage sidecar, so it never rewrites
the fact file.
2026-11-02 17:41:01 -08:00
esengine 87c91ebd06 refactor(memory): copy usage records with maps.Copy 2026-11-02 17:23:53 -08:00
esengine bfc838a3ce feat(memory): a forgetting curve, use reinforcement and links between facts
Each fact has a retention of e^(-days since last use / strength). Reading a
fact multiplies its strength by 1.8 and an automatic recall by 1.2, capped at
365 days; constraints, pinned and evergreen facts never fade. Recall keeps a
clear fact's score, scales a fuzzy one down and holds a faded one at 0.35 so a
strong match still comes back. Facts recalled in the same turn build a link,
and a link of weight two brings its neighbour along. Usage lives in a
.usage.json beside the facts, so a recorded use never rewrites a fact file or
moves its revision. memory search and read report the state.
2026-11-02 17:21:32 -08:00
esengine eef0e8590b fix(memory): a one-letter galaxy name never counts as named by the turn 2026-11-02 17:05:09 -08:00
esengine c22dd9495d feat(memory): rank recall by importance and bring constraints along
Automatic recall scores the name, title, description, galaxy and body as one
field and the synonyms as a second field at 0.6, then weights the result by
importance (0.85, 1.0, 1.15) and a 1.1 boost for facts used in the last 30
days. A constraint is brought whenever its galaxy is named in the turn or is
the galaxy of one of the three best hits, or when its own score reaches 8% of
the best, and it leads the block; at most three are held and an expired one is
never recalled.
2026-11-02 17:05:09 -08:00
esengine ffe1b34bda docs(release-notes): note the manual compaction fix 2026-11-02 17:03:42 -08:00
esengine cee09e76ba fix(context): a manual compaction above the trigger folds when it frees space
Compacting by hand accepted any real saving only while the context was still
below the automatic trigger. Above it, the request had to land below the
trigger or was declined, so the moment compaction was most needed the user
was told there was nothing to compact. A manual request now accepts any fold
that is strictly smaller; an unchanged context and too little new history are
still declined as before.
2026-11-02 17:03:39 -08:00
YHH bbb9cf74a9 Merge pull request #14943 from lancy69/fix/13655-renderer-safe-skill-warnings
fix(skills): render first and new discovery warnings safely in TUI
2026-11-02 16:55:10 -08:00
esengine b70010f018 docs(release-notes): split the task budget note to fit the line limit 2026-11-02 16:53:59 -08:00
esengine 5bc01bedd2 docs(release-notes): note the removed task budgets 2026-11-02 16:53:33 -08:00
esengine a341d5615e refactor(config): remove the user task budgets
task_cost_budget, task_time_budget_minutes and task_token_budget no longer
exist: a task is not capped by what it spends. Old config files carrying them
are cleaned on start with a one-time notice, and the settings declaration and
config template drop them. The budget machinery a Goal run injects for itself
(goal_token_budget, off by default) and the run usage accounting stay. The
effect tests that only exercised the configured budgets are removed, and the
Windows CI steps that named them are pruned in the same commit.
2026-11-02 16:39:16 -08:00
esengine cb46820a92 test: the CI owner check no longer expects the removed isolation package 2026-11-02 16:35:27 -08:00
esengine da640949c8 feat(memory): store a galaxy, importance, strength and use count per fact
Facts gain optional galaxy, importance (1 to 3), strength (days), last-used
time and use count in their frontmatter, and a constraint type that never
fades. Absent fields resolve to defaults on read, so existing files stay
valid and are not rewritten; an update that omits them keeps what is stored.
A constraint is written with a project routing type so an older binary
still files it correctly. The remember tool accepts galaxy and importance,
and memory read reports them.
2026-11-02 16:19:16 -08:00
esengine 562449cd9d fix(desktop): drop the compaction threshold actions and strings that nothing renders 2026-11-02 16:00:53 -08:00
esengine 863f66cb7e test: update the compact ratio default the CLI reports 2026-11-02 15:57:06 -08:00
esengine 2662c799d6 ci: drop the Windows root auxiliary budget step whose tests were removed 2026-11-02 15:51:27 -08:00
esengine 7e1cdccb8d refactor(context): drop the soft compaction paths and default to 0.90
Context maintenance follows one trigger: a share of the model window, now
0.90 by default for new configs (existing files keep what they saved).
Removed the absolute-size economic boundary and its setting, HTTP route and
settings UI, the draggable fold handle in the context panel, the retired
multi-threshold and cold-resume keys (old files are cleaned on start), and
the failure-output elision applied to retained messages during a fold,
including the extra model call that picked the lines to keep.
2026-11-02 15:50:49 -08:00
esengine 4aeb419ea9 ci: stop testing the removed isolation package 2026-11-02 15:50:06 -08:00
esengine 2e2a484781 ci: stop requiring Windows tests for features that were removed
The pull-request-only Windows steps listed tests by name and failed when one
was missing. The sub-agent, isolation, background-job and carried-proof
rewrites deleted a number of them, so every pull request failed its Windows
check. Drop the steps whose whole list is gone, prune the removed names from
the rest, and remove the concurrency inventory runner whose two inventories
named nothing that exists.
2026-11-02 09:29:27 -08:00
esengine 611094ba17 docs(release-notes): note the sub-agent rewrite and the removed checks 2026-11-02 08:53:08 -08:00
esengine ad2c963475 refactor(delegation): replace the task family with spawn_agents
Rewrite internal/runtime/delegation around one Runner and one tool.
spawn_agents runs 1 to 8 independent tasks in parallel, each in its own
session with three base tools (read_file, write_file, bash); plan mode and
read-only skills keep a read-only child. A child's full answer is archived;
the caller gets the opening, a reference, and the files the child read and
wrote, and pages the rest through read_subagent_result.

Removed with it: task, read_only_task, parallel_tasks, fleet,
list_subagents, background sub-tasks, steering and its limits and settings
UI, continue_from, write_paths claims, isolated worktrees, best-of-n, the
run graph and the runtime-resume benchmark. Stale writes by children stay
refused through the per-agent file views.

The use_capability description no longer names task:fleet, so the stable
prefix and its golden files change once.
2026-11-02 08:52:45 -08:00
esengine 0bb0f44785 refactor(delegation): sub-agents return prose, not a structured completion report
complete_subtask, the host adjudication of its criteria and the 'lowered by the
host' note are gone, with the handoff audit channel and its metrics, trajectory
field and benchmark columns. A delegated child ends the way any turn does and
the parent reads its last reply.

The host's own receipts appended to a child's reply and the write-scope check
stay: they need a record of what the child changed, which is replaced with the
evidence ledger later.
2026-11-02 07:16:52 -08:00
esengine 5766a8ebeb refactor(tools,plan): complete_step takes no evidence; drop conclude_blocked and plan criteria
complete_step now only says a step is done: it names the step and what is now
true, and the host advances the task list as before. It no longer asks for
commands, files or criteria to cite and no longer checks them against the
session's receipts.

conclude_blocked existed to let the readiness gate through and goes with it;
the prompts that pointed at it now ask for a sentence in the reply. Plan steps
lose their acceptance and verification fields: the submit_plan schema, the
planner and executor prompts, the rendering and the re-approval rule all
stop mentioning them, and plans saved with them still load.

The executor's no-action nudge now keys on a plan naming files only.
2026-11-02 06:33:28 -08:00
esengine 3d0fe85e7e refactor(event): remove the readiness and audit channels nothing sends to
With the final-readiness gate gone, FinalReadinessError, the readiness audit
and the structured readiness on TurnDone have no producer, and neither do the
contract-shadow, completion-report, evidence-bundle, outcome-progress,
protocol-recovery, verification-drift and project-check-probe audits. Removes
the types, the sink capabilities and their forwarding in every wrapper, the
trajectory and metrics fields, the final_readiness outcome and notice code,
and the delivery-recovery submit port.

The ACP status shape and the desktop reader of old sessions keep their
readiness fields: external editors and recorded sessions still carry them.
2026-11-02 05:59:36 -08:00
esengine 8c785fa4b0 refactor(cli,config): remove --fail-on-unverified and agent.host_verification
Both only steered the final-readiness gate, which no longer exists: the flag
chose exit 3 for an unmet readiness verdict and the setting chose whether a
changed turn was held for a check, a review and a sign-off. Neither can do
anything now.

`run` still exits 4 when an untrusted folder refused its edits. A user config
that still carries host_verification loads unchanged; the key is ignored.
Removes the HoldOwedChecks and WaiveOwedChecks plumbing behind them.
2026-11-02 05:27:28 -08:00
esengine b364c1693c refactor(agent): remove the final-readiness gate, review gate and baseline checks
A turn that ends with its answer is done. The agent no longer asks, at the
point of stopping, whether a check ran after the last write, whether a
high-risk change was reviewed, or whether captured test criteria still hold;
it no longer ends a run with a readiness error, and the controller no longer
re-runs the turn to satisfy one.

Goal keeps its update_goal report and the evaluator, and still holds a
complete claim while the task list is unfinished; it no longer reads a
readiness result or freezes a verification contract. Review sub-agents are
ordinary sub-agents: nothing requires them to file a typed report.

Removes the readiness, owed-review, baseline-criteria, carried-proof and
review-report code, the delivery checkpoint, the readiness continuation, the
orphaned helpers they left in evidence, and the tests that asserted the removed
behaviour.
2026-11-02 05:15:57 -08:00
Will 0bbcefcb59 docs(release-notes): record #14940 2026-11-02 21:14:15 +08:00
Will 448b8bdf5c docs(release-notes): record #14940 2026-11-02 21:14:12 +08:00
Will b6a1d9b5f3 fix(history): a search that could not read a source says so
Cause: the catalog hides a source with health<>'ok' from the query
(internal/state/historycatalog/catalog.go:520) and drops a candidate whose
transcript fails to load after RecordReadFailure, but /history/search answered
with hits only. The rail renders nothing for zero hits, so a search over an
incomplete index reached it as the same answer as a query that matched
nothing. The Agent history tool already carries the incomplete notice; the
desktop half lost its equivalent when the Wails history panel
(partial + status.indexed/total) was replaced by frontend-next.

Fix: /history/search answers {hits, index{state,indexed,total,failed,partial,
reason}}, read from the counters the catalog already publishes after the
search, with one shared predicate (history.SearchIndexProgress) that the
history tool now uses too. The rail renders one line while index.partial:
"could not be indexed" for reason=failed, the catalog's own progress for
reason=building. A complete index renders nothing extra.

Blast radius: the /history/search wire shape changes from a bare array to
{hits,index}; its only consumer is the Electron rail in this tree. A degraded
in-memory projection no longer reports "still building" once its counters
agree it answered for every source.

Verification: new serve tests drive an unreadable event log end to end and
assert the response carries failed/partial with zero hits; a control asserts a
healthy index adds nothing for a matching or an empty query. The rail tests
cover the failed line, the building line, matches kept beside the line, and the
silent healthy case.
2026-11-02 21:08:37 +08:00
Will e5a6c3ae93 fix(acp): a lease refusal says which refusal it is
A session method refused because another runtime owns the transcript answered
-32600 and prose, the code a malformed frame gets; an ownership probe that could
not decide answered -32603, the same as a disk that would not write. A client
could not tell "held" from "unreadable" from "your request was broken", so it
could not decide whether to ask the person to close a window or retry.

session/new, session/load and session/resume now answer -32022 with data.code
busy.session_in_use for a held lease and -32023 with data.code
session.lease_unprovable when the store's probe cannot prove ownership either
way; -32600 is left to a genuinely malformed request. The human message stays.
The HTTP frontend, the kernel, the lease and the inbox are untouched.

docs/ACP.md states the table and what a client does with each answer. repolint's
family-vs-writer check is now parameterised by family; it reads the lease
sentinels beside the inbox ones, so a lease sentinel added without a coded case
fails by name against the ACP writer.
2026-11-02 21:00:01 +08:00
esengine 9eacc9afa9 test(plugin): drop the Linux probe test whose subject was removed
The unconfined-launch probe it exercised was deleted with the sealing
removal; the Linux-only file did not compile on the CI runners.
2026-11-02 04:34:53 -08:00
Lancy 82c27867b3 fix(skills): render runtime discovery warnings as notices 2026-11-02 12:27:26 +00:00
esengine fb02f44834 refactor(agent): stop sealing, shadowing and receipting each turn
A turn no longer builds a shadow task contract, a completion report or a
sealed evidence bundle, takes a workspace snapshot, or hands the host a
completion receipt. Removes the agent-side producers and their wiring in boot,
the observation and verdict packages that only they used, and the
write-protection and unconfined-launch probes that only boot read.

The receipt and audit shapes on the event wire, the terminal and the desktop
settings stay for a later cleanup; nothing sends them now.
2026-11-02 04:01:03 -08:00
esengine be5c98c744 refactor(agent): remove missing-tool-call-reasoning detection and recovery
The loop no longer classifies a tool-call turn that arrived without
reasoning, retries it, or keeps a file-backed cooldown for it. Removes the
watch and the cooldown state, the deferred stream sink they needed, the
provider policy interfaces and their implementations, the descriptor field on
the extension wire (schema and SDK regenerated), the state-dir path, the
run-metrics counters and the protocol-recovery kinds.

The protocol-recovery audit channel itself stays for now.
2026-11-02 03:26:03 -08:00
YHH a87c4d1fff Merge pull request #14941 from esengine/fx/pr-author-gate
ci: limit pull requests to maintainers and an allowlist of invited authors
2026-11-02 02:06:23 -08:00
YHH ea2c04d046 Merge pull request #14940 from lancy69/fix/14936-reasoning-without-answer
fix(agent): recover DeepSeek reasoning-only final answers
2026-11-02 01:55:03 -08:00
esengine 60caacd887 ci: limit pull requests to maintainers and an allowlist of invited authors 2026-11-02 01:52:42 -08:00
Lancy 860651f866 fix(agent): recover DeepSeek reasoning-only final answers 2026-11-02 09:11:50 +00:00
Lancy 2f63333c56 fix(desktop): prevent version download text overlap 2026-11-02 07:35:20 +00:00
YHH c43bd2bdc3 Merge pull request #14902 from esengine/dependabot/npm_and_yarn/desktop/electron/electron-45.1.0
chore(deps-dev): bump electron from 44.7.2 to 45.1.0 in /desktop/electron
2026-11-01 19:39:13 -08:00
esengine 457b586331 build(windows): declare electron_wer.dll and drop dxil.dll for Electron 45
Electron 45 ships electron_wer.dll, unsigned, and no longer ships dxil.dll.
Sign electron_wer.dll with the other unsigned PE images, and stop requiring
a Microsoft-signed dxil.dll. Checked against the 45.1.0 win32 x64 and arm64
archives: both hold exactly the declared set.
2026-11-01 19:10:41 -08:00
esengine 678cf19f03 Merge studio into dependabot/electron-45.1.0 2026-11-01 19:10:24 -08:00
kevin9327 35791b47dd Merge branch 'studio' into fix/gitignore-worktree-info-exclude 2026-11-02 11:59:48 +09:00
dependabot[bot] 91b5467cba chore(deps-dev): bump electron in /desktop/electron
Bumps [electron](https://github.com/electron/electron) from 44.7.2 to 45.1.0.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v44.7.2...v45.1.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 45.1.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-11-01 11:57:55 +00:00
kevin 5b7f6a3057 fix(gitignore): shorten the infoExcludeFile comment to the repolint limit 2026-10-29 07:55:44 +09:00
kevin9327 fc63bc00a6 fix(gitignore): read the gitdir, commondir and HEAD pointers through the injected reader
infoExcludeFile now reads .git, commondir and HEAD with the same reader
At uses for the ignore files, instead of its own os.Stat/os.ReadFile calls.
A .git directory or a missing file reads as nothing, so the plain-repository
path is unchanged, and a pointer is still followed only to a directory with
a non-empty HEAD file. This drops the new path sinks CodeQL flagged.
2026-10-28 15:13:11 +09:00
kevin9327 b3c6f06533 fix(gitignore): only follow gitdir and commondir pointers to a directory with HEAD 2026-10-28 09:21:36 +09:00
YHH e956185f58 Merge branch 'studio' into fix/gitignore-worktree-info-exclude 2026-10-27 04:07:01 -07:00
kevin9327 23c7322789 fix(gitignore): read info/exclude from the common dir when .git is a file
Signed-off-by: kevin9327 <[email protected]>
2026-10-24 22:56:14 +09:00
2163 changed files with 44132 additions and 111985 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Logins allowed to open pull requests, one per line, matched case-insensitively.
# Repository owners, members and collaborators, and bots, are always allowed.
lancy69
chuiwenwei
xxoingr
kevin9327
imcreazy
+3
View File
@@ -2,6 +2,9 @@
Target branch: 2.x features and fixes go to `studio`. 1.x takes bug fixes,
provider/API compatibility, release/updater, security and platform stability
only, on `main-v2`.
Pull requests are limited to maintainers and invited contributors; one from
anyone else is closed automatically. Open an issue instead.
-->
## Summary
@@ -1,24 +0,0 @@
[
{
"package": "reasonix/internal/runtime/delegation",
"tests": [
"TestAncestorCopyAdmissionCoversUnpublishedRun",
"TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_continue",
"TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_legacy",
"TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_continue",
"TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_legacy",
"TestAncestorCopyAdmissionAllowsDifferentParents",
"TestAncestorCopyAdmissionReleasesAbandonedPreparation",
"TestAncestorCopyAdmissionReleasesAfterInvalidCopy",
"TestAncestorCopyAdmissionAllowsDifferentSources",
"TestAncestorCopyAdmissionUsesNearestSourceIdentity",
"TestAncestorCopyAdmissionReleasesAfterBusyCurrentReference",
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata",
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata/continue_from",
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata/fork_from",
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground",
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground/continue_from",
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground/fork_from"
]
}
]
@@ -1,26 +0,0 @@
[
{
"package": "reasonix/internal/runtime/agent",
"tests": [
"TestCarriedProofInvalidatesPermissionChange",
"TestCarriedProofFileStateCompatibility",
"TestCarriedProofFileStateCompatibility/legacy_file",
"TestCarriedProofFileStateCompatibility/legacy_absent",
"TestCarriedProofFileStateCompatibility/future",
"TestCarriedProofFileStateCompatibility/current_file",
"TestCarriedProofFileStateCompatibility/current_absent",
"TestCarriedProofRejectsDirectoryState",
"TestCarriedProofStandsOnlyForUnchangedContent",
"TestCarriedProofSeesADeletion"
]
},
{
"package": "reasonix/internal/assembly/boot",
"tests": [
"TestEffectCarriedProofTracksFileState",
"TestEffectCarriedProofTracksFileState/unchanged",
"TestEffectCarriedProofTracksFileState/content",
"TestEffectCarriedProofTracksFileState/mode"
]
}
]
+164 -317
View File
@@ -15,7 +15,7 @@ permissions:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/studio' }}
jobs:
# Cheap path gate for pull requests. PRs confined to docs/release-notes
@@ -170,23 +170,6 @@ jobs:
shell: pwsh
run: Get-CimInstance Win32_Processor | Select-Object -ExpandProperty Name
- name: test (Windows concurrency contracts)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
node --test scripts/windows-concurrency-tests.test.mjs
node scripts/windows-concurrency-tests.mjs "$RUNNER_TEMP/concurrency-windows.jsonl"
- name: upload Windows concurrency inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: concurrency-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/concurrency-windows.jsonl
if-no-files-found: error
retention-days: 7
- name: test
if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
env:
@@ -196,35 +179,6 @@ jobs:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test ./...
- name: test (Windows subagent status admission)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^TestSubagent(PersistedStatusAdmission|UnknownStatusStillReadableForDiagnosis)$' ./internal/runtime/delegation | tee "$RUNNER_TEMP/subagent-status-admission.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
["future-paused", "missing", "empty", "COMPLETED", "completed", "cancelled", "failed", "interrupted", "running"] as $states |
["continue", "copy", "ancestor", "legacy_ancestor", "current_copy"] as $routes |
(["TestSubagentPersistedStatusAdmission", "TestSubagentUnknownStatusStillReadableForDiagnosis"] +
[$states[] | "TestSubagentPersistedStatusAdmission/" + .] +
[$states[] as $state | $routes[] | "TestSubagentPersistedStatusAdmission/" + $state + "/" + .]) as $required |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/delegation"] - $packages | length) == 0 and
($required - $tests | length) == 0
' "$RUNNER_TEMP/subagent-status-admission.jsonl"
- name: upload Windows subagent status admission inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: subagent-status-admission-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/subagent-status-admission.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows composition links)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
@@ -301,40 +255,21 @@ jobs:
shell: bash
run: |
set -o pipefail
go test -count=1 -p 4 -timeout=18m -json ./internal/runtime/isolation/... ./internal/platform/worktree/... ./internal/state/mutation/... | tee "$RUNNER_TEMP/isolation-owners.jsonl" |
go test -count=1 -p 4 -timeout=18m -json ./internal/platform/worktree/... ./internal/state/mutation/... | tee "$RUNNER_TEMP/isolation-owners.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $passed |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
(["reasonix/internal/runtime/isolation", "reasonix/internal/platform/worktree", "reasonix/internal/state/mutation"] - $passed | length) == 0 and
(["TestResultOperationUsesCrossProcessOwnership",
"TestActualResultOperationHoldsOwnershipThroughWriteAndCleanup/restore",
"TestActualResultOperationHoldsOwnershipThroughWriteAndCleanup/remove",
"TestApplyPathsWithUntrustedModesPreservesTheIndex/content",
"TestApplyPathsWithUntrustedModesPreservesTheIndex/source_index_mode",
"TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_mode",
"TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_type",
"TestApplyPathsEmulatedLinksUseSnapshotFilters",
"TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings",
(["reasonix/internal/platform/worktree", "reasonix/internal/state/mutation"] - $passed | length) == 0 and
(["TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings",
"TestLegacyWaitDoesNotConsumeExecutionCapacity"] - $tests | length) == 0
' "$RUNNER_TEMP/isolation-owners.jsonl"
jq -se '
. as $events |
{"reasonix/internal/platform/worktree": [
"TestApplyPathsRejectsRedirectedAncestors",
"TestApplyPathsRejectsRedirectedAncestors/outside_delete",
"TestApplyPathsRejectsRedirectedAncestors/inside_delete",
"TestApplyPathsRejectsRedirectedAncestors/dangling_delete",
"TestApplyPathsRejectsRedirectedAncestors/outside_write",
"TestApplyPathsRejectsRedirectedAncestors/inside_write",
"TestApplyPathsRejectsRedirectedAncestors/regular_parent",
"TestApplyPathsRejectsRedirectedAncestors/unchanged",
"TestApplyPathsRejectsRedirectedAncestors/missing_creation",
"TestWriteChangesRejectsRedirectedDeletion",
"TestWriteChangesUnlinksLeafDirectoryLink",
"TestWholeWorkspaceApplyPreservesFileToDirectoryTransition"],
"reasonix/internal/runtime/isolation": [
"TestApplyToolRetainsResultAfterAncestorReplacement"]} |
"TestWriteChangesUnlinksLeafDirectoryLink"
]} |
to_entries | all(.[]; . as $package |
([ $events[] | select(.Package == $package.key and .Test == null and
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
@@ -354,60 +289,15 @@ jobs:
if-no-files-found: ignore
retention-days: 7
- name: test (Windows background resource owners)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 25
shell: bash
run: |
set -o pipefail
go test -count=1 -p 2 -timeout=18m -json ./internal/tools/jobs/... ./internal/runtime/delegation/... | tee "$RUNNER_TEMP/background-resources.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
(["reasonix/internal/tools/jobs", "reasonix/internal/runtime/delegation"] - $packages | length) == 0 and
(["TestJobFinalizationCoversInvokedAndUninvoked/success",
"TestJobFinalizationCoversInvokedAndUninvoked/error",
"TestJobFinalizationCoversInvokedAndUninvoked/panic",
"TestJobFinalizationCoversInvokedAndUninvoked/closed",
"TestJobFinalizationCoversInvokedAndUninvoked/invalid_session",
"TestJobFinalizationCoversInvokedAndUninvoked/invalid_kind",
"TestJobFinalizationFailuresAttemptAllCallbacksAndKeepCauses/invoked",
"TestJobFinalizationFailuresAttemptAllCallbacksAndKeepCauses/uninvoked",
"TestJobFinalizationPrecedesCompletionPublication",
"TestJobFinalizationCloseBetweenPublicationAndLaunch",
"TestJobFinalizationWaitsForClosureAfterLogicalCancellation",
"TestBackgroundNoLaunchFinalizesOwnedResources/task/closed_manager",
"TestBackgroundNoLaunchFinalizesOwnedResources/task/invalid_session",
"TestBackgroundNoLaunchFinalizesOwnedResources/fleet/closed_manager",
"TestBackgroundNoLaunchFinalizesOwnedResources/fleet/invalid_session"] - $tests | length) == 0
' "$RUNNER_TEMP/background-resources.jsonl"
- name: upload Windows background resource inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: background-resources-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/background-resources.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows delegated Goal usage scope)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(UsageScope.*|DelegatedUsageKeepsGoalOwner|EffectDelegatedUsageKeepsGoalOwner)$' ./internal/contract/event ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/goal-usage-scope.jsonl"
go test -count=1 -timeout=3m -json -run '^Test(UsageScope.*)$' ./internal/contract/event ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/goal-usage-scope.jsonl"
jq -se '
["TestUsageScopePreservesCapturedOwnerAndWire", "TestUsageScopeKeepsEarlierEventOwner",
"TestUsageScopeWithoutCapability", "TestDelegatedUsageKeepsGoalOwner",
"TestDelegatedUsageKeepsGoalOwner/chat_to_goal", "TestDelegatedUsageKeepsGoalOwner/replaced_goal",
"TestDelegatedUsageKeepsGoalOwner/cleared_goal", "TestDelegatedUsageKeepsGoalOwner/resumed_goal",
"TestDelegatedUsageKeepsGoalOwner/same_goal_next_turn", "TestDelegatedUsageKeepsGoalOwner/between_turns",
"TestDelegatedUsageKeepsGoalOwner/nested_after_replacement", "TestEffectDelegatedUsageKeepsGoalOwner",
"TestEffectDelegatedUsageKeepsGoalOwner/chat_to_goal", "TestEffectDelegatedUsageKeepsGoalOwner/replaced_goal",
"TestEffectDelegatedUsageKeepsGoalOwner/nested_chat_to_goal", "TestEffectDelegatedUsageKeepsGoalOwner/same_goal",
"TestEffectDelegatedUsageKeepsGoalOwner/completed_before_goal"] as $required |
["TestUsageScopeKeepsEarlierEventOwner",
"TestUsageScopeWithoutCapability" ] as $required |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
all(.[]; .Action != "fail" and .Action != "skip") and
@@ -424,60 +314,19 @@ jobs:
if-no-files-found: error
retention-days: 7
- name: test (Windows planner local budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^TestEffectPlannerConfiguredTaskBudget$' ./internal/assembly/boot | tee "$RUNNER_TEMP/planner-local-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestEffectPlannerConfiguredTaskBudget", "TestEffectPlannerConfiguredTaskBudget/tokens",
"TestEffectPlannerConfiguredTaskBudget/cost", "TestEffectPlannerConfiguredTaskBudget/wall",
"TestEffectPlannerConfiguredTaskBudget/unconfigured", "TestEffectPlannerConfiguredTaskBudget/explicit_zero",
"TestEffectPlannerConfiguredTaskBudget/unpriced_cost", "TestEffectPlannerConfiguredTaskBudget/missing_usage_cost",
"TestEffectPlannerConfiguredTaskBudget/context_bound", "TestEffectPlannerConfiguredTaskBudget/context_disables",
"TestEffectPlannerConfiguredTaskBudget/executor_only"] - $tests | length) == 0
' "$RUNNER_TEMP/planner-local-budget.jsonl"
- name: upload Windows planner local budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: planner-local-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/planner-local-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows Controller routing root budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(SemanticRootUsage|EffectRoutingRootBudget)$' ./internal/runtime/capability ./internal/assembly/boot | tee "$RUNNER_TEMP/controller-routing-budget.jsonl"
go test -count=1 -timeout=3m -json -run '^Test(SemanticRootUsage)$' ./internal/runtime/capability | tee "$RUNNER_TEMP/controller-routing-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/capability", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["reasonix/internal/runtime/capability"] - $packages | length) == 0 and
([
"TestEffectRoutingRootBudget",
"TestEffectRoutingRootBudget/balanced",
"TestEffectRoutingRootBudget/cached_continuation",
"TestEffectRoutingRootBudget/cached_new_scope",
"TestEffectRoutingRootBudget/cached_new_task",
"TestEffectRoutingRootBudget/cached_reset",
"TestEffectRoutingRootBudget/context_zero",
"TestEffectRoutingRootBudget/dual",
"TestEffectRoutingRootBudget/headless",
"TestEffectRoutingRootBudget/interactive",
"TestEffectRoutingRootBudget/missing",
"TestEffectRoutingRootBudget/unconfigured",
"TestSemanticRootUsage",
"TestSemanticRootUsage/billed_error",
"TestSemanticRootUsage/cache",
@@ -505,22 +354,17 @@ jobs:
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(PreparedRootBudget|CoordinatorRootBudget|EffectCoordinatorRoot)' ./internal/runtime/agent ./internal/runtime/coordinator ./internal/assembly/boot | tee "$RUNNER_TEMP/coordinator-root-budget.jsonl"
go test -count=1 -timeout=3m -json -run '^Test(PreparedRootBudget|CoordinatorRootBudget|EffectCoordinatorRoot)' ./internal/runtime/agent ./internal/runtime/coordinator | tee "$RUNNER_TEMP/coordinator-root-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/agent", "reasonix/internal/runtime/coordinator", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestPreparedRootBudgetLifetime", "TestPreparedRootBudgetLifetime/continuation",
"TestPreparedRootBudgetLifetime/new_scope", "TestPreparedRootBudgetLifetime/new_task",
"TestPreparedRootBudgetLifetime/reset", "TestPreparedRootBudgetLifetime/new_session",
"TestPreparedRootBudgetProjectionAndPreservedTurn", "TestCoordinatorRootBudgetRoutes",
(["reasonix/internal/runtime/agent", "reasonix/internal/runtime/coordinator"] - $packages | length) == 0 and
(["TestCoordinatorRootBudgetRoutes",
"TestCoordinatorRootBudgetRoutes/raw", "TestCoordinatorRootBudgetRoutes/tool_planner",
"TestCoordinatorRootBudgetRoutes/failure_fallback", "TestCoordinatorRootBudgetRoutes/plan_only_continuation",
"TestCoordinatorRootBudgetRoutes/approval_declined_continuation", "TestCoordinatorRootBudgetRoutes/approval_allowed",
"TestEffectCoordinatorRootTokenBudget", "TestEffectCoordinatorRootTokenBudget/shared",
"TestEffectCoordinatorRootTokenBudget/unconfigured", "TestEffectCoordinatorRootTokenBudget/context_disables",
"TestEffectCoordinatorRootTokenBudget/missing_planner", "TestEffectCoordinatorRootTokenBudget/executor_only"] - $tests | length) == 0
"TestCoordinatorRootBudgetRoutes/approval_declined_continuation", "TestCoordinatorRootBudgetRoutes/approval_allowed"
] - $tests | length) == 0
' "$RUNNER_TEMP/coordinator-root-budget.jsonl"
- name: upload Windows Coordinator root budget inventory
@@ -578,10 +422,8 @@ jobs:
"TestRootWallBudgetReachesDelegatedAgents/context", "TestRootWallBudgetReachesDelegatedAgents/unconfigured",
"TestRootWallBudgetReachesDelegatedAgents/explicit_zero", "TestRootWallBudgetContinuationAndReset",
"TestRootWallBudgetContinuationAndReset/continuation", "TestRootWallBudgetContinuationAndReset/new_task",
"TestRootWallBudgetContinuationAndReset/reset", "TestRootWallBudgetContinuationAndReset/override",
"TestRootWallBudgetProjectionKeepsJobDeadline", "TestEffectRootWallBudgetReachesManagedJobs",
"TestEffectRootWallBudgetReachesManagedJobs/task", "TestEffectRootWallBudgetReachesManagedJobs/capability",
"TestEffectRootWallBudgetReachesManagedJobs/fleet"] - $tests | length) == 0
"TestRootWallBudgetContinuationAndReset/reset", "TestRootWallBudgetContinuationAndReset/override"
] - $tests | length) == 0
' "$RUNNER_TEMP/root-wall-budget.jsonl"
- name: upload Windows root wall budget inventory
@@ -599,22 +441,19 @@ jobs:
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(RootToken|EffectRootToken|BudgetCountsKnownReportedTokens|ReportedToken|EffectReportedTotalTokenBudget)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-token-budget.jsonl"
go test -count=1 -timeout=3m -json -run '^Test(RootToken|EffectRootToken|BudgetCountsKnownReportedTokens|ReportedToken)' ./internal/runtime/agent | tee "$RUNNER_TEMP/root-token-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["reasonix/internal/runtime/agent"] - $packages | length) == 0 and
(["TestRootTokenBudgetIncludesChildProviderUsage", "TestRootTokenBudgetIncludesChildProviderUsage/configured",
"TestRootTokenBudgetIncludesChildProviderUsage/context", "TestRootTokenBudgetIncludesChildProviderUsage/unconfigured",
"TestRootTokenBudgetIncludesChildProviderUsage/explicit_zero", "TestRootTokenBudgetSurvivesNestedAndConcurrentChildren",
"TestRootTokenBudgetSurvivesNestedAndConcurrentChildren/nested", "TestRootTokenBudgetSurvivesNestedAndConcurrentChildren/siblings",
"TestRootTokenBudgetSurvivesNestedAndConcurrentChildren/child_override", "TestRootTokenBudgetContinuationAndReset",
"TestRootTokenBudgetContinuationAndReset/continuation", "TestRootTokenBudgetContinuationAndReset/new_task",
"TestRootTokenBudgetContinuationAndReset/reset", "TestRootTokenScopeAtomicAccountingAndJobCancellation",
"TestEffectRootTokenBudgetReachesDelegatedJobs", "TestEffectRootTokenBudgetReachesDelegatedJobs/foreground",
"TestEffectRootTokenBudgetReachesDelegatedJobs/foreground_capability", "TestEffectRootTokenBudgetReachesDelegatedJobs/background",
"TestEffectRootTokenBudgetReachesDelegatedJobs/background_capability", "TestEffectRootTokenBudgetReachesDelegatedJobs/background_fleet"] - $tests | length) == 0
"TestRootTokenBudgetContinuationAndReset/reset" ] - $tests | length) == 0
' "$RUNNER_TEMP/root-token-budget.jsonl"
jq -se '
. as $events |
@@ -628,17 +467,7 @@ jobs:
"TestBudgetCountsKnownReportedTokens/negative_split",
"TestBudgetCountsKnownReportedTokens/zero",
"TestBudgetCountsKnownReportedTokens/negative",
"TestReportedTokensEnforceLocalCapOnInheritedRoot",
"TestReportedTokensFollowOriginalAuxiliaryRoot",
"TestReportedTokenCountersSaturate"],
"reasonix/internal/assembly/boot": [
"TestEffectReportedTotalTokenBudget",
"TestEffectReportedTotalTokenBudget/split",
"TestEffectReportedTotalTokenBudget/total_only",
"TestEffectReportedTotalTokenBudget/partial_split",
"TestEffectReportedTotalTokenBudget/lower_total",
"TestEffectReportedTotalTokenBudget/missing_total",
"TestEffectReportedTotalTokenBudget/disabled"]} |
"TestReportedTokenCountersSaturate"]} |
to_entries | all(.[]; . as $package |
([ $events[] | select(.Package == $package.key and .Test == null and
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
@@ -697,7 +526,7 @@ jobs:
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(HostStorageEnvironment|HostObjectStorage|HostExplicitStorageControls|DetachedStorageEnvironment|WorktreePreparationOwnsItsIndex|EffectIsolatedTaskOwnsGitStorage)$' ./internal/platform/gitcmd ./internal/platform/worktree ./internal/assembly/boot | tee "$RUNNER_TEMP/git-storage-environment.jsonl"
go test -count=1 -timeout=3m -json -run '^Test(HostStorageEnvironment|HostObjectStorage|HostExplicitStorageControls|DetachedStorageEnvironment)$' ./internal/platform/gitcmd ./internal/platform/worktree ./internal/assembly/boot | tee "$RUNNER_TEMP/git-storage-environment.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
@@ -707,10 +536,6 @@ jobs:
"TestDetachedStorageEnvironment",
"TestDetachedStorageEnvironment/explicit_env",
"TestDetachedStorageEnvironment/inherited_env",
"TestEffectIsolatedTaskOwnsGitStorage",
"TestEffectIsolatedTaskOwnsGitStorage/ambient_existing",
"TestEffectIsolatedTaskOwnsGitStorage/ambient_missing",
"TestEffectIsolatedTaskOwnsGitStorage/control",
"TestHostExplicitStorageControls",
"TestHostExplicitStorageControls/explicit_object_directory",
"TestHostExplicitStorageControls/private_index",
@@ -721,15 +546,8 @@ jobs:
"TestHostStorageEnvironment",
"TestHostStorageEnvironment/GIT_ALTERNATE_OBJECT_DIRECTORIES",
"TestHostStorageEnvironment/GIT_INDEX_FILE",
"TestHostStorageEnvironment/GIT_OBJECT_DIRECTORY",
"TestWorktreePreparationOwnsItsIndex",
"TestWorktreePreparationOwnsItsIndex/candidate_ambient_existing",
"TestWorktreePreparationOwnsItsIndex/candidate_ambient_missing",
"TestWorktreePreparationOwnsItsIndex/candidate_control",
"TestWorktreePreparationOwnsItsIndex/delivery_ambient_existing",
"TestWorktreePreparationOwnsItsIndex/delivery_ambient_missing",
"TestWorktreePreparationOwnsItsIndex/delivery_control"
] - $tests | length) == 0
"TestHostStorageEnvironment/GIT_OBJECT_DIRECTORY"
] - $tests | length) == 0
' "$RUNNER_TEMP/git-storage-environment.jsonl"
- name: upload Windows Git storage environment inventory
@@ -779,20 +597,19 @@ jobs:
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(RootWebSearch|EffectRootWebSearch)' ./internal/tools/websearch ./internal/assembly/boot | tee "$RUNNER_TEMP/root-websearch-budget.jsonl"
go test -count=1 -timeout=3m -json -run '^Test(RootWebSearch|EffectRootWebSearch)' ./internal/tools/websearch | tee "$RUNNER_TEMP/root-websearch-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/tools/websearch", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["reasonix/internal/tools/websearch"] - $packages | length) == 0 and
(["TestRootWebSearchUsageTerminal", "TestRootWebSearchUsageTerminal/reported",
"TestRootWebSearchUsageTerminal/no_reporter", "TestRootWebSearchUsageTerminal/missing",
"TestRootWebSearchUsageTerminal/no_observer", "TestRootWebSearchUsageTerminal/input_refused",
"TestRootWebSearchUsageTerminal/no_factory", "TestRootWebSearchUsageTerminal/factory_error",
"TestRootWebSearchUsageTerminal/billed_failure", "TestRootWebSearchUsageTerminal/cancel_after_usage",
"TestRootWebSearchUsageTerminal/cancel_before_start", "TestRootWebSearchUsageTerminal/stream_error",
"TestEffectRootWebSearchTokenBudget", "TestEffectRootWebSearchTokenBudget/reported",
"TestEffectRootWebSearchTokenBudget/disabled", "TestEffectRootWebSearchTokenBudget/missing"] - $tests | length) == 0
"TestRootWebSearchUsageTerminal/cancel_before_start", "TestRootWebSearchUsageTerminal/stream_error"
] - $tests | length) == 0
' "$RUNNER_TEMP/root-websearch-budget.jsonl"
- name: upload Windows root websearch budget inventory
@@ -810,21 +627,13 @@ jobs:
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^(TestProfileRootBudgetLifetime|TestProfileBudgetPreparationUsesTurnAdmission|TestEffectExplicitSkillRootBudget)$' ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/explicit-skill-root-budget.jsonl"
go test -count=1 -timeout=3m -json -run '^(TestProfileRootBudgetLifetime|TestProfileBudgetPreparationUsesTurnAdmission|TestEffectExplicitSkillRootBudget)$' ./internal/session/control | tee "$RUNNER_TEMP/explicit-skill-root-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/session/control", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestProfileRootBudgetLifetime", "TestProfileRootBudgetLifetime/new_task",
"TestProfileRootBudgetLifetime/same_scope", "TestProfileRootBudgetLifetime/new_scope",
"TestProfileRootBudgetLifetime/reset", "TestProfileRootBudgetLifetime/new_session",
"TestProfileRootBudgetLifetime/inherited", "TestProfileRootBudgetLifetime/context_zero",
"TestProfileBudgetPreparationUsesTurnAdmission", "TestEffectExplicitSkillRootBudget",
"TestEffectExplicitSkillRootBudget/profile", "TestEffectExplicitSkillRootBudget/preview",
"TestEffectExplicitSkillRootBudget/slash", "TestEffectExplicitSkillRootBudget/chips",
"TestEffectExplicitSkillRootBudget/goal", "TestEffectExplicitSkillRootBudget/context_zero",
"TestEffectExplicitSkillRootBudget/unconfigured"] - $tests | length) == 0
(["reasonix/internal/session/control"] - $packages | length) == 0 and
(["TestProfileBudgetPreparationUsesTurnAdmission" ] - $tests | length) == 0
' "$RUNNER_TEMP/explicit-skill-root-budget.jsonl"
- name: upload Windows explicit skill root budget inventory
@@ -842,19 +651,16 @@ jobs:
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(RootTriage|EffectRootTriage)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-triage-budget.jsonl"
go test -count=1 -timeout=3m -json -run '^Test(RootTriage|EffectRootTriage)' ./internal/runtime/agent | tee "$RUNNER_TEMP/root-triage-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["reasonix/internal/runtime/agent"] - $packages | length) == 0 and
(["TestRootTriageTokenBudget", "TestRootTriageTokenBudget/classifier", "TestRootTriageTokenBudget/screen",
"TestRootTriageTokenBudget/billed_failure", "TestRootTriageTokenBudget/missing", "TestRootTriageTokenBudget/disabled",
"TestRootTriageTokenBudget/no_sink", "TestRootTriageTokenBudget/counts_without_total",
"TestRootTriageOutsideRunDoesNotCreateScope", "TestEffectRootTriageScreenBudget",
"TestEffectRootTriageScreenBudget/reported", "TestEffectRootTriageScreenBudget/flagged",
"TestEffectRootTriageScreenBudget/disabled", "TestEffectRootTriageScreenBudget/missing",
"TestEffectRootTriageScreenBudget/screen_disabled"] - $tests | length) == 0
"TestRootTriageOutsideRunDoesNotCreateScope" ] - $tests | length) == 0
' "$RUNNER_TEMP/root-triage-budget.jsonl"
- name: upload Windows root triage budget inventory
@@ -866,54 +672,22 @@ jobs:
if-no-files-found: ignore
retention-days: 7
- name: test (Windows root auxiliary budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(RootAuxiliary|EffectRootAuxiliary)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-auxiliary-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestRootAuxiliaryAutomaticCompaction", "TestRootAuxiliaryTokenBudget",
"TestRootAuxiliaryTokenBudget/summary", "TestRootAuxiliaryTokenBudget/summary_failed",
"TestRootAuxiliaryTokenBudget/summary_missing", "TestRootAuxiliaryTokenBudget/summary_disabled",
"TestRootAuxiliaryTokenBudget/diagnosis", "TestRootAuxiliaryTokenBudget/diagnosis_failed",
"TestRootAuxiliaryTokenBudget/diagnosis_missing", "TestRootAuxiliaryTokenBudget/diagnosis_disabled",
"TestRootAuxiliaryManualCompactionOutsideRun", "TestRootAuxiliaryBudgetProjectionKeepsOldTask",
"TestEffectRootAuxiliarySummaryBudget", "TestEffectRootAuxiliarySummaryBudget/configured",
"TestEffectRootAuxiliarySummaryBudget/disabled", "TestEffectRootAuxiliarySummaryBudget/missing_usage"] - $tests | length) == 0
' "$RUNNER_TEMP/root-auxiliary-budget.jsonl"
- name: upload Windows root auxiliary budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: root-auxiliary-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/root-auxiliary-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows local cost quote)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(LocalBudgetUsesOccurrenceQuote|LocalCost.*|UsageQuote.*|EffectLocalCostUsesOccurrenceQuote)$' ./internal/contract/event ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/local-cost-quote.jsonl"
go test -count=1 -timeout=3m -json -run '^Test(LocalBudgetUsesOccurrenceQuote|LocalCost.*|UsageQuote.*)$' ./internal/contract/event ./internal/runtime/agent | tee "$RUNNER_TEMP/local-cost-quote.jsonl"
jq -se '
["TestLocalBudgetUsesOccurrenceQuote", "TestLocalBudgetUsesOccurrenceQuote/peak", "TestLocalBudgetUsesOccurrenceQuote/offpeak",
"TestLocalCostKeepsOriginalCurrencyBuckets", "TestLocalCostFixedPointThresholdAndUnknown",
"TestUsageQuoteUsesOneOwnerAndOccurrence", "TestUsageQuoteLegacyAndNilSinks",
"TestEffectLocalCostUsesOccurrenceQuote", "TestEffectLocalCostUsesOccurrenceQuote/peak",
"TestEffectLocalCostUsesOccurrenceQuote/offpeak", "TestEffectLocalCostUsesOccurrenceQuote/relay"] as $required |
"TestUsageQuoteUsesOneOwnerAndOccurrence", "TestUsageQuoteLegacyAndNilSinks"
] as $required |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
all(.[]; .Action != "fail" and .Action != "skip") and
(($required - $passed) | length == 0) and
((["reasonix/internal/contract/event", "reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages) | length == 0)
((["reasonix/internal/contract/event", "reasonix/internal/runtime/agent"] - $packages) | length == 0)
' "$RUNNER_TEMP/local-cost-quote.jsonl"
- name: upload Windows local cost quote inventory
@@ -963,24 +737,18 @@ jobs:
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(AuxiliaryUsageObserver|BoundedUsageObserver|RootBounded|EffectRootBounded)' ./internal/contract/provider ./internal/model/boundedllm ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/bounded-root-budget.jsonl"
go test -count=1 -timeout=3m -json -run '^Test(AuxiliaryUsageObserver|BoundedUsageObserver|RootBounded|EffectRootBounded)' ./internal/contract/provider ./internal/model/boundedllm ./internal/runtime/agent | tee "$RUNNER_TEMP/bounded-root-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/contract/provider", "reasonix/internal/model/boundedllm",
"reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
"reasonix/internal/runtime/agent"] - $packages | length) == 0 and
(["TestAuxiliaryUsageObserverReplacesAndCopies",
"TestBoundedUsageObserverOwnsOneTerminal", "TestBoundedUsageObserverOwnsOneTerminal/reported",
"TestBoundedUsageObserverOwnsOneTerminal/billed_failure", "TestBoundedUsageObserverOwnsOneTerminal/without_sink",
"TestBoundedUsageObserverOwnsOneTerminal/missing", "TestBoundedUsageObserverOwnsOneTerminal/input_refused",
"TestRootBoundedReviewerBudget", "TestRootBoundedReviewerBudget/reported",
"TestRootBoundedReviewerBudget/billed_failure", "TestRootBoundedReviewerBudget/missing_usage",
"TestRootBoundedReviewerBudget/disabled", "TestRootBoundedReviewerBudget/no_emission",
"TestRootBoundedReviewerBudget/projected_job_context", "TestRootBoundedNestedBudgetChargesOnce",
"TestRootBoundedLateProjectionStaysWithOldTask", "TestEffectRootBoundedAdvisorBudget",
"TestEffectRootBoundedAdvisorBudget/reported", "TestEffectRootBoundedAdvisorBudget/disabled",
"TestEffectRootBoundedAdvisorBudget/missing_usage"] - $tests | length) == 0
"TestBoundedUsageObserverOwnsOneTerminal/missing", "TestBoundedUsageObserverOwnsOneTerminal/input_refused"
] - $tests | length) == 0
' "$RUNNER_TEMP/bounded-root-budget.jsonl"
- name: upload Windows bounded root budget inventory
@@ -1009,42 +777,6 @@ jobs:
timeout-minutes: 5
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
# Full push suite: a 36m package timeout inside a 45m step budget. -count=1
# as at the smoke step. A failure prints the failed tests' output and the 15
# slowest boot tests.
- name: test (full)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
timeout-minutes: 45
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
# built around.
REASONIX_RELEASE_CACHE_GUARD: "1"
# Bound sandbox helper children in Windows tests so a failed OS-level
# launch cannot pin the Actions step after Go's package timeout fires.
WINDOWS_SANDBOX_WAIT_MS: "20000"
shell: bash
run: |
# internal/assembly/boot alone can take over 25 minutes on a Windows runner;
# the package limit leaves headroom inside the step limit above.
go test -count=1 -p 4 -timeout=36m -json ./... | tee "$RUNNER_TEMP/full.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
- name: test (full) failure detail
if: failure() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
shell: bash
run: |
f="$RUNNER_TEMP/full.jsonl"
[ -s "$f" ] || exit 0
echo "::group::output of failed tests"
jq -rn '[inputs | select(.Test != null)] | group_by(.Package + "/" + .Test)
| map(select(any(.[]; .Action == "fail")))[]
| map(select(.Action == "output") | .Output) | join("")' "$f" || true
echo "::endgroup::"
echo "slowest internal/assembly/boot tests (seconds, name):"
jq -rn '[inputs | select((.Package | endswith("/internal/assembly/boot")) and .Test != null and .Action == "pass")]
| sort_by(-.Elapsed)[:15][] | "\(.Elapsed)\t\(.Test)"' "$f" || true
race:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
@@ -1079,15 +811,130 @@ jobs:
if: github.event_name == 'pull_request'
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./internal/runtime/agent/... ./internal/runtime/delegation/... ./internal/runtime/isolation/... ./internal/platform/worktree/... ./internal/state/mutation/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/...
run: go test -race ./internal/runtime/agent/... ./internal/runtime/delegation/... ./internal/platform/worktree/... ./internal/state/mutation/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/...
- name: test -race (full)
if: github.event_name != 'pull_request'
# Push-only full suites. internal/frontend/serve and internal/assembly/boot run
# hundreds of serial tests and set the wall time of one whole-tree `go test`,
# so each is cut by test name into shards that run on their own runners. The
# -run / -skip patterns partition the names exactly: every test lands in one
# shard. `rest` is every other package. Pull requests keep the focused steps
# in the `test` and `race` jobs above.
test-windows-shards:
if: github.event_name != 'pull_request'
name: test (windows-latest, ${{ matrix.shard }})
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
include:
- { shard: rest }
- { shard: serve-1, pkg: ./internal/frontend/serve, flag: -run, filter: '^Test[A-E]' }
- { shard: serve-2, pkg: ./internal/frontend/serve, flag: -run, filter: '^Test[F-R]' }
- { shard: serve-3, pkg: ./internal/frontend/serve, flag: -skip, filter: '^Test[A-R]' }
- { shard: boot-1, pkg: ./internal/assembly/boot, flag: -skip, filter: '^TestEffect([^0-9A-C]|$)' }
- { shard: boot-2, pkg: ./internal/assembly/boot, flag: -run, filter: '^TestEffect[D-O]' }
- { shard: boot-3, pkg: ./internal/assembly/boot, flag: -run, filter: '^TestEffect([^0-9A-O]|$)' }
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- name: test (shard)
timeout-minutes: 40
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
# built around.
REASONIX_RELEASE_CACHE_GUARD: "1"
# Bound sandbox helper children in Windows tests so a failed OS-level
# launch cannot pin the Actions step after Go's package timeout fires.
WINDOWS_SANDBOX_WAIT_MS: "20000"
SHARD: ${{ matrix.shard }}
PKG: ${{ matrix.pkg }}
FLAG: ${{ matrix.flag }}
FILTER: ${{ matrix.filter }}
shell: bash
run: |
if [ "$SHARD" = rest ]; then
mapfile -t pkgs < <(go list ./... | tr -d '\r' | grep -v -E '/internal/(frontend/serve|assembly/boot)$')
else
pkgs=("$PKG")
fi
filter=()
if [ -n "$FILTER" ]; then filter=("$FLAG" "$FILTER"); fi
go test -count=1 -p 4 -timeout=36m -json "${filter[@]}" "${pkgs[@]}" | tee "$RUNNER_TEMP/shard.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
- name: test (shard) failure detail
if: failure()
shell: bash
run: |
f="$RUNNER_TEMP/shard.jsonl"
[ -s "$f" ] || exit 0
echo "::group::output of failed tests"
jq -rn '[inputs | select(.Test != null)] | group_by(.Package + "/" + .Test)
| map(select(any(.[]; .Action == "fail")))[]
| map(select(.Action == "output") | .Output) | join("")' "$f" || true
echo "::endgroup::"
echo "slowest tests (seconds, package, name):"
jq -rn '[inputs | select(.Test != null and .Action == "pass")]
| sort_by(-.Elapsed)[:15][] | "\(.Elapsed) \(.Package) \(.Test)"' "$f" || true
race-shards:
if: github.event_name != 'pull_request'
name: race (${{ matrix.shard }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { shard: rest }
- { shard: serve-1, pkg: ./internal/frontend/serve, flag: -run, filter: '^Test[A-E]' }
- { shard: serve-2, pkg: ./internal/frontend/serve, flag: -run, filter: '^Test[F-R]' }
- { shard: serve-3, pkg: ./internal/frontend/serve, flag: -skip, filter: '^Test[A-R]' }
- { shard: boot-1, pkg: ./internal/assembly/boot, flag: -skip, filter: '^TestEffect([^0-9A-C]|$)' }
- { shard: boot-2, pkg: ./internal/assembly/boot, flag: -run, filter: '^TestEffect[D-O]' }
- { shard: boot-3, pkg: ./internal/assembly/boot, flag: -run, filter: '^TestEffect([^0-9A-O]|$)' }
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- name: Install and verify Linux sandbox backend
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap
if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
sudo sysctl -w kernel.unprivileged_userns_clone=1
fi
if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
bwrap --ro-bind / / --dev /dev --proc /proc -- true || echo "::warning::bubblewrap is installed but this runner denies user namespaces; unavailable-backend tests will run fail-closed"
- name: test -race (shard)
timeout-minutes: 30
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
# internal/assembly/boot under -race runs past ten minutes on a shared
# runner; the package limit leaves headroom over the default.
run: go test -race -timeout=25m ./...
SHARD: ${{ matrix.shard }}
PKG: ${{ matrix.pkg }}
FLAG: ${{ matrix.flag }}
FILTER: ${{ matrix.filter }}
run: |
if [ "$SHARD" = rest ]; then
mapfile -t pkgs < <(go list ./... | grep -v -E '/internal/(frontend/serve|assembly/boot)$')
else
pkgs=("$PKG")
fi
filter=()
if [ -n "$FILTER" ]; then filter=("$FLAG" "$FILTER"); fi
go test -race -timeout=25m "${filter[@]}" "${pkgs[@]}"
# sdk/go is a nested stdlib-only module invisible to root `go test ./...`.
# Its DTOs are generated from internal/ext/extension/protocol, and the
+27
View File
@@ -0,0 +1,27 @@
name: PR author gate
# Pull requests are limited to maintainers and the logins in .github/pr-authors.txt.
# This runs in the base repository's context, checks out the base branch only, and
# never executes pull-request code; the allowlist it reads is the base branch's.
on:
pull_request_target:
types: [opened]
permissions:
contents: read
pull-requests: write
concurrency:
group: pr-author-gate-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- run: node scripts/pr-author-gate.mjs
env:
GH_TOKEN: ${{ github.token }}
+1 -1
View File
@@ -16,7 +16,7 @@ permissions:
concurrency:
group: studio-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/studio' }}
jobs:
frontend:
-4
View File
@@ -103,10 +103,6 @@ func newHarness(t *testingDir, p provider.Provider, window int, rec *callRecorde
ContextWindow: window,
ArchiveDir: filepath.Join(t.dir, "archive"),
SessionPath: path,
RecentKeep: 4,
// boot's default when cfg.Agent.Keep is unset; without it the bench
// would measure a configuration no real session runs.
KeepPolicy: agent.KeepErrors,
}, rec.sink())
return &harness{sess: sess, agentA: a, path: path, calls: rec}
}
@@ -108,7 +108,6 @@ func newAgent(p provider.Provider, sess *sessionstore.Session, path string, sink
return agent.New(p, tool.NewRegistry(), sess, agent.Options{
ContextWindow: windowTokens,
CompactRatio: compactRatio,
RecentKeep: 2,
SessionPath: path,
WorkspaceID: "cm-e2e",
ModelRef: "deepseek/" + model,
-180
View File
@@ -1,180 +0,0 @@
# Context retrieval bench
Two questions about long-horizon memory, and the harness that makes their
answers trustworthy.
-mode=preflight build all 18 tasks under every arm and check the contract
-mode=snippet does a rank-1 hit hand over the whole answer?
-mode=adversarial send a model after the answer through every host surface
-mode=run-search Experiment R: is searchable canonical a real capability?
-mode=run-boundary Experiment I, paired: the 6 efficiency tasks across their boundary
-mode=run-index Experiment I, full: the 6 efficiency tasks x 4 budgets
-mode=calibrate find each index task's PlantAfterGen for its cue tier
`-dry` drives any run mode with a scripted provider, for nothing. Real runs need
`DEEPSEEK_API_KEY`.
## Status
**Searchable canonical recall: proven.** Six of six recovered with search on,
three of six with it off, and the successes without it came from enumerating
addresses by hand at roughly nine times the page-in cost (139 vs 1292
tokens/task). Measured after three separate leaks were closed, so the numbers
are from a clean environment.
**Fold index marginal utility: direction reproduced, size still conditional.** On the v2
substrate the direction holds in six of six tasks and in the pooled paired batch: with the
cue visible the index addresses the target, so the same task is searched less and pages in
less (0.67 searches and 70 recall tokens per task, against 1.50 and 120 with the cue
absent).
A size is visible but is not yet quotable. On the four tasks whose absent-cue arm stayed
out of a loop, the index cost 64,952 prompt tokens against 115,551 without, 44% less
(`i106-budgetseal` -35,147, `i70-ingestfloor` -8,222, `i37-dispatchlag` -7,599, and
`i112-lagquota` +369, the prefix rent a task that never searches pays). That is a
**post-hoc subset**: `i118-fencelag` and `i47-tenantlease` are excluded because their
absent-cue arm looped in this batch (five and three retrieval rounds, three escapes each;
together 80% of the absent-cue total), and the qualification above does not screen for
stopping behaviour — holding on both boundary arms across three passes shows a stable cue
boundary, not a stopping arm. Quoting 44% as the index's effect would mean choosing the
sample after seeing the result; a batch screened on stopping stability is what would make
a size quotable, and one arm also reverses either way (`i70-ingestfloor` default, +11,135
prompt tokens over three escapes).
Batch facts, because the counts differ: 36 runs were executed (12 paired, 24 index), and
the token totals here cover the 24-run index batch — the paired batch ran before the
per-run usage line existed.
**Things that turned out not to be problems**, each after being measured rather
than argued about:
| Suspected | Measured |
| --- | --- |
| Model writes poor queries (2.17 searches/task) | 22/23 found the target on query 1; the extra searches are one round's parallel fan-out |
| Model reaches for the workspace before memory (7/11) | Linear event order misread; by model round it is MemoryFirst 4/6 |
| Snippets too short for multi-value answers | 240 runes covers 12/12 tasks at 100%; records are 30-179 runes |
| A general stopping failure | 37/44 runs issued no search after the answer was in hand; all 7 that did belong to 2 tasks |
## Measurement distinctions this bench had to learn
Each of these reversed a conclusion once. They are enforced in the schema now,
not left to whoever reads the numbers next.
- **TargetHit is not EvidenceSufficient.** A rank-1 hit can return a window too
short to answer with. `FirstHitCoverage` records how much of the scored answer
the first hit actually handed over.
- **Linear event order is not model-round order.** Two calls in one round are a
parallel fan-out, not a preference. Routing compares rounds.
- **An isolated workdir is not an isolated host.** The sandbox mounts the host
read-only by design. Three leaks were found and closed: the corpus source, the
fixture transcript, and its event log sidecar.
- **A count is not its evidence.** Query text, snippet contents and trajectories
are all persisted now, because three analyses in a row needed a paid re-run to
ask a question of data already collected.
## How a run stays honest
The corpus holds templates, never answers: every scored literal is a `{{var}}`
instantiated per run, so grepping this directory reveals the question and not
the answer. `TestNoAnswerLiteralExistsInTheRepository` asserts it with
`git grep`. Preflight rebuilds every arm and checks the answer is unreadable in
the real `provider.Request`, the probe query ranks the target within five, and
an index task's cue sits at exactly the scales its tier names. The fixture is
removed from disk once the agent holds it in memory, and the directory is
scanned for answer literals before the first provider request and again after
the turn. Any answer reaching the model through a tool that is not `recall`
marks the run contaminated and takes it out of every statistic.
## Index corpus v2
The efficiency substrate is six tasks that hold on both arms: `i37-dispatchlag`
and `i118-fencelag` (quarter), `i106-budgetseal` and `i70-ingestfloor` (half),
`i47-tenantlease` and `i112-lagquota` (default).
They came out of five batches of candidates, each screened the same way: one
`-mode=run-index` pass over the batch, then two more only for the candidates that
were clean on both boundary arms, and only the candidates that held on both arms
in all three passes stayed. A candidate that does not hold is dropped, not
promoted. The numbers are candidates, per tier:
| batch | quarter | half | default |
| --- | --- | --- | --- |
| first pool, `i31`-`i54` | 1 of 8 | 0 of 8 | 2 of 8 |
| second pool, `i61`-`i84` | 1 of 4 screened | 4 of 8 | 0 of 8 |
| third pool, `i85`-`i108` | 2 of 8 | 8 of 16 | none authored |
| replacement pool, `i109`-`i116` | - | - | 4 of 8 |
| replacement pool, `i92`, `i117`-`i124` | 3 of 9 | - | - |
That table counts candidates that held on both arms in all three passes, which is
not the size of the substrate. 89 candidates were authored, 77 were screened (the
second pool's quarter tier stopped at 4 of 8 and its default tier was never run),
and 25 held: 7 quarter, 12 half, 6 default. The substrate keeps two per tier, so
the other 19 survivors are spares rather than corpus members. Candidates from the
earlier round are not counted here.
| task | tier | cue present | cue absent |
| --- | --- | ---: | ---: |
| `i37-dispatchlag` | quarter | 3/3 | 3/3 |
| `i118-fencelag` | quarter | 3/3 | 3/3 |
| `i106-budgetseal` | half | 3/3 | 3/3 |
| `i70-ingestfloor` | half | 3/3 | 3/3 |
| `i47-tenantlease` | default | 3/3 | 3/3 |
| `i112-lagquota` | default | 3/3 | 3/3 |
Across their 36 judged cells: `SnippetStop` 23, `SnippetThenRead` 10 and
`DefensiveRead` 3 - every cell clean, nothing post-sufficient and nothing
`NeverSufficient`.
Four of the first six were flagged in review for sharing answer values, probe
queries and prompt wording within a tier. Separating them and re-screening those
four changed two of them: `i37` and `i47` held, `i62` and `i51` did not, so they
were replaced - `i90-recoverylag` was tried first and did not hold either, and
`i118-fencelag` and `i112-lagquota` are what did. The separation was not
cosmetic: the shared wording had been carrying two of the six.
Nothing in the task shape separates the survivors from the rest: within the half
tier, plain-number answers survived 0 of 8 in the first pool and 4 of 8 in the
second, and codename answers survived 8 of 16 in the third. An earlier claim here
that the answer kind decides whether a candidate holds does not survive this
table and is withdrawn. The dirty class was `PostSufficientRetrieval` in most
failures - the model re-checking a value it already had - but which tasks hit it
was not predictable from their shape at this sample.
`i01`-`i06` are the frozen study's six tasks, kept as `roleStopping`. Measured
once each with `-mode=run-index -task <id>`, per task and per arm:
| task | tier | cue present | cue absent |
| --- | --- | --- | --- |
| `i01-transport-fallback` | quarter | SnippetStop | DefensiveRead |
| `i02-scheduler-handoff` | quarter | PostSufficientRetrieval | PostSufficientRetrieval |
| `i03-coalescing` | half | PostSufficientRetrieval | PostSufficientRunaway |
| `i04-recovery-fence` | half | DefensiveRead | PostSufficientRunaway |
| `i05-cache-probe` | default | SnippetThenRead | PostSufficientRetrieval |
| `i06-dispatch-handoff` | default | SnippetStop | PostSufficientRetrieval |
Five of the six show post-sufficient behaviour in at least one arm (`i02`-`i06`),
and `i01` is clean on both. The cue-present arm is clean for four of them
(`i01`, `i04`, `i05`, `i06`), which is why they stay in the corpus as the
stopping side rather than being retitled. All six are excluded from `run-index`
and `run-boundary` batches and stay measurable one at a time with `-task <id>`. A
task added to the corpus declares a role, so it cannot arrive without one.
A task qualifies only if it is clean on both of its boundary arms - the
cue-present run and the cue-absent run - in every run of a screening batch. Clean
means a `stopping_class` other than `PostSufficientRetrieval` or
`PostSufficientRunaway`: `SnippetStop`, `SnippetThenRead` and `DefensiveRead`
all qualify, and a defensive read is deliberately not counted as waste. The
class comes from the three counts in that report — `searches_after_sufficient`,
`reads_after_sufficient` and `escapes_after_sufficient` — together with how
sufficiency was reached and how many rounds followed it. The remaining
requirements still apply — successful tool call, answer in the output rather
than the cue, rank 1, `FirstHitCoverage` complete, one or two simple values,
nothing in the workspace.
`-mode=calibrate` is not a screening tool: it builds and reads back the
fixtures, and reads nothing else, so its cue-visibility profile is the same for
every task.
Then repeats rather than levels: cue-present against cue-absent on each task's
own boundary, three runs per cell. What is unknown is the variance, and four
budgets sampled once each cannot show it.
@@ -1,86 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// runBlockedBatch runs one dry index batch where every arm's runOne fails
// before any model turn: the task's own directory is a regular file, so the
// fixture cannot be built. In production the same early return happens when the
// fixture build, the session load, or the answer-on-disk guard fails.
func runBlockedBatch(t *testing.T) string {
t.Helper()
tk := indexTasks()[0]
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, tk.ID), []byte("not a directory\n"), 0o644); err != nil {
t.Fatal(err)
}
if code := runExperiment("run-index", experimentIndex, root, true, []contextTask{tk}); code != 0 {
t.Fatalf("runExperiment exit = %d, want 0 (every run failed, but the batch finished)", code)
}
return root
}
func readResultLines(t *testing.T, root string) ([]contextMetrics, []string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join(root, "results.jsonl"))
if err != nil {
t.Fatal(err)
}
var runs []contextMetrics
var lines []string
for text := range strings.SplitSeq(strings.TrimSpace(string(raw)), "\n") {
var m contextMetrics
if err := json.Unmarshal([]byte(text), &m); err != nil {
t.Fatalf("results.jsonl: %v", err)
}
runs = append(runs, m)
lines = append(lines, text)
}
return runs, lines
}
// A run that failed before the model turn produced no measurement. Recording it
// as a zero-valued contextMetrics puts it in the funnel's Scored denominator,
// in "Where it broke", and in the dose curve as a real zero.
func TestBatchDoesNotRecordAFailedRunAsAResult(t *testing.T) {
root := runBlockedBatch(t)
runs, lines := readResultLines(t, root)
if len(runs) == 0 {
t.Fatal("no runs recorded at all")
}
for i, m := range runs {
if m.Task == "" || m.Arm == "" || m.FailureStage == "" {
t.Fatalf("results.jsonl line %d records a run that never ran as a result "+
"(task=%q arm=%q failure_stage=%q):\n %s", i+1, m.Task, m.Arm, m.FailureStage, lines[i])
}
}
}
// The manifest is the artefact that says which runs counted, so a failed run
// must not be added to CompletedRuns or to the task id list.
func TestBatchManifestDoesNotReportFailedRunsAsCompleted(t *testing.T) {
root := runBlockedBatch(t)
var manifest batchManifest
data, err := os.ReadFile(filepath.Join(root, "batch.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatal(err)
}
for _, id := range manifest.TaskIDs {
if id == "" {
t.Fatalf("manifest lists an empty task id: task_ids=%q (completed=%d planned=%d)",
manifest.TaskIDs, manifest.CompletedRuns, manifest.PlannedRuns)
}
}
if manifest.CompletedRuns == manifest.PlannedRuns {
t.Fatalf("manifest reports every planned run completed although none produced evidence: "+
"completed=%d planned=%d task_ids=%q", manifest.CompletedRuns, manifest.PlannedRuns, manifest.TaskIDs)
}
}
-155
View File
@@ -1,155 +0,0 @@
package main
import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
// batchInfo is what the mode itself knows before any run happens. Everything
// else in the manifest is derived from the runs that were written, so a figure
// in it cannot drift from the artifact beside it.
type batchInfo struct {
Mode string
Planned int
Started time.Time
Dry bool
// Repo is the checkout the batch ran from, captured before the harness
// isolates its workspace: git cannot describe a directory that is gone.
Repo string
}
// batchManifest is written beside results.jsonl so a later reader does not have
// to reconstruct what the batch was from a terminal scrollback.
type batchManifest struct {
SchemaVersion int `json:"schema_version"`
Mode string `json:"mode"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
GitCommit string `json:"git_commit"`
GitDirty *bool `json:"git_dirty,omitempty"`
Model string `json:"model"`
Window int `json:"window"`
Generations int `json:"generations"`
TaskIDs []string `json:"task_ids"`
Arms []string `json:"arms"`
PlannedRuns int `json:"planned_runs"`
CompletedRuns int `json:"completed_runs"`
// FailedRuns counts the planned runs that ended before a transcript existed.
// They are rows in results.jsonl, never part of CompletedRuns, and the field
// is absent when there were none so an intact batch is unchanged.
FailedRuns int `json:"failed_runs,omitempty"`
// Usage totals are absent when nothing reported any: an unmeasured cost must
// not read as a measured zero. UsageReportedRuns stays, because zero there
// is a fact about the batch.
UsageReportedRuns int `json:"usage_reported_runs"`
UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"`
CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
UsageEstimated bool `json:"usage_estimated,omitempty"`
ResultsSHA256 string `json:"results_sha256"`
TrajectoriesSHA256 string `json:"trajectories_sha256"`
}
// gitCommitAndDirty reports the checkout the batch ran from. Unknown is written
// rather than omitted, so an absent field is never mistaken for a clean tree.
func gitCommitAndDirty(repo string) (commit string, dirty *bool) {
commit = "unknown"
if repo == "" {
return commit, nil
}
in := func(args ...string) ([]byte, error) {
cmd := exec.Command("git", args...)
cmd.Dir = repo
return cmd.Output()
}
if out, err := in("rev-parse", "--short", "HEAD"); err == nil {
commit = strings.TrimSpace(string(out))
}
if out, err := in("status", "--porcelain"); err == nil {
state := strings.TrimSpace(string(out)) != ""
dirty = &state
}
return commit, dirty
}
func fileSHA256(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", sha256.Sum256(data)), nil
}
func sortedKeys(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
// writeBatchManifest records the batch beside the runs it describes. It runs
// last, so its digests are of what is already on disk.
func writeBatchManifest(root string, info batchInfo, all []contextMetrics) error {
ids, arms := map[string]bool{}, map[string]bool{}
var prompt, completion, cacheHit, cacheMiss, requests, reported, failed int
estimated := false
for _, m := range all {
// A failed run has no task, arm or evidence to contribute: it is counted
// as a failure below and is not part of what the batch measured.
if m.Failed {
failed++
continue
}
ids[m.Task] = true
arms[m.Arm] = true
if m.UsageReportedRequests > 0 {
reported++
requests += m.UsageReportedRequests
prompt += m.PromptTokens
completion += m.CompletionTokens
cacheHit += m.CacheHitTokens
cacheMiss += m.CacheMissTokens
estimated = estimated || m.UsageEstimated
}
}
commit, dirty := gitCommitAndDirty(info.Repo)
model := runModel
if info.Dry {
model = "scripted"
}
manifest := batchManifest{
SchemaVersion: 1, Mode: info.Mode,
StartedAt: info.Started.UTC().Format(time.RFC3339),
FinishedAt: time.Now().UTC().Format(time.RFC3339),
GitCommit: commit, GitDirty: dirty,
Model: model, Window: fixtureWindow, Generations: fixtureGenerations,
TaskIDs: sortedKeys(ids), Arms: sortedKeys(arms),
PlannedRuns: info.Planned, CompletedRuns: len(all) - failed, FailedRuns: failed,
UsageReportedRuns: reported, UsageReportedRequests: requests,
PromptTokens: prompt, CompletionTokens: completion,
CacheHitTokens: cacheHit, CacheMissTokens: cacheMiss, UsageEstimated: estimated,
}
var err error
if manifest.ResultsSHA256, err = fileSHA256(filepath.Join(root, "results.jsonl")); err != nil {
return err
}
if manifest.TrajectoriesSHA256, err = fileSHA256(filepath.Join(root, "trajectories.jsonl")); err != nil {
return err
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(root, "batch.json"), append(data, '\n'), 0o644)
}
@@ -1,91 +0,0 @@
package main
import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func seedArtifacts(t *testing.T, root string) {
t.Helper()
for _, name := range []string{"results.jsonl", "trajectories.jsonl"} {
if err := os.WriteFile(filepath.Join(root, name), []byte("{}\n"), 0o644); err != nil {
t.Fatal(err)
}
}
}
func readManifest(t *testing.T, root string) (batchManifest, string) {
t.Helper()
data, err := os.ReadFile(filepath.Join(root, "batch.json"))
if err != nil {
t.Fatal(err)
}
var got batchManifest
if err := json.Unmarshal(data, &got); err != nil {
t.Fatal(err)
}
return got, string(data)
}
func TestBatchManifestRecordsWhatTheBatchWas(t *testing.T) {
root := t.TempDir()
seedArtifacts(t, root)
all := []contextMetrics{
{Task: "b", Arm: "index-off"},
{Task: "a", Arm: "index-off"},
{Task: "a", Arm: "index-half", UsageReportedRequests: 2, PromptTokens: 100,
CompletionTokens: 10, CacheHitTokens: 60, CacheMissTokens: 40},
}
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 4, Started: time.Now().Add(-time.Minute)}, all); err != nil {
t.Fatal(err)
}
got, _ := readManifest(t, root)
if got.SchemaVersion != 1 || got.Mode != "run-index" || got.PlannedRuns != 4 || got.CompletedRuns != 3 {
t.Fatalf("manifest header = %+v", got)
}
if strings.Join(got.TaskIDs, ",") != "a,b" || strings.Join(got.Arms, ",") != "index-half,index-off" {
t.Fatalf("ids/arms = %v / %v, want sorted and unique", got.TaskIDs, got.Arms)
}
if got.UsageReportedRuns != 1 || got.UsageReportedRequests != 2 || got.PromptTokens != 100 ||
got.CompletionTokens != 10 || got.CacheHitTokens != 60 || got.CacheMissTokens != 40 {
t.Fatalf("usage totals = %+v", got)
}
// No repo means git could not describe the checkout: unknown commit, and no
// dirty flag at all, because "clean" is a claim this batch cannot make.
if got.GitCommit != "unknown" || got.GitDirty != nil {
t.Fatalf("git state = %q / %v, want unknown and no dirty flag", got.GitCommit, got.GitDirty)
}
want := fmt.Sprintf("%x", sha256.Sum256([]byte("{}\n")))
if got.ResultsSHA256 != want || got.TrajectoriesSHA256 != want {
t.Fatalf("digests = %q / %q, want %q", got.ResultsSHA256, got.TrajectoriesSHA256, want)
}
}
// A dry batch pays nothing, so its cost fields must be absent rather than zero:
// an unmeasured spend must not read as a measured one.
func TestBatchManifestLeavesUnreportedUsageAbsent(t *testing.T) {
root := t.TempDir()
seedArtifacts(t, root)
all := []contextMetrics{{Task: "a", Arm: "index-off"}, {Task: "a", Arm: "index-half"}}
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 2, Started: time.Now(), Dry: true}, all); err != nil {
t.Fatal(err)
}
got, raw := readManifest(t, root)
if got.UsageReportedRuns != 0 {
t.Fatalf("usage_reported_runs = %d, want 0 reported", got.UsageReportedRuns)
}
if got.Model != "scripted" {
t.Fatalf("dry batch model = %q, want scripted", got.Model)
}
for _, absent := range []string{"usage_reported_requests", "prompt_tokens", "completion_tokens", "cache_hit_tokens"} {
if strings.Contains(raw, absent) {
t.Fatalf("a dry batch wrote %q:\n%s", absent, raw)
}
}
}
@@ -1,69 +0,0 @@
package main
import (
"strings"
"testing"
)
// boundaryRuns builds the input reportBoundaries reads: one scored run for an
// index task's cue side, and one failed run for another index task's cue side.
// The runs are constructed because the report is a pure function of the slice;
// the batch path that produces them is covered by the failed-batch tests.
func boundaryRuns(t *testing.T) (all []contextMetrics, scoredTask, failedTask string) {
t.Helper()
index := indexTasks()
if len(index) < 2 {
t.Fatalf("need at least two index tasks, have %d", len(index))
}
scored, failed := index[0], index[1]
cue, noCue := boundaryPair(scored.CueTier)
if cue == "" || noCue == "" {
t.Fatalf("task %s has an empty boundary pair (%q, %q)", scored.ID, cue, noCue)
}
failedCue, _ := boundaryPair(failed.CueTier)
if failedCue == "" {
t.Fatalf("task %s has no cue side", failed.ID)
}
all = []contextMetrics{
{Task: scored.ID, Arm: "index-" + cue, AnswerRecovered: true, SearchCalls: 1, RecallReturnedTokens: 318},
{Task: scored.ID, Arm: "index-" + noCue, AnswerRecovered: false},
{Task: failed.ID, Arm: "index-" + failedCue, Failed: true, FailureStage: stageNoEvidence},
}
return all, scored.ID, failed.ID
}
// TestBoundaryReportExcludesFailedRuns: a run that never produced a transcript
// must not appear in the cue-boundary table, which was the last reporting exit
// that still folded it in as a zero measurement.
func TestBoundaryReportExcludesFailedRuns(t *testing.T) {
all, scoredTask, failedTask := boundaryRuns(t)
out := captureStdout(t, func() { reportBoundaries(all) })
if strings.Contains(out, failedTask) {
t.Fatalf("the boundary table printed the failed task %q as a measurement:\n%s", failedTask, out)
}
if !strings.Contains(out, scoredTask) {
t.Fatalf("the boundary table dropped the scored task %q, so the exclusion is not specific to failures:\n%s", scoredTask, out)
}
if !strings.Contains(out, "cue-present") || !strings.Contains(out, "cue-absent") {
t.Fatalf("the pooled comparison disappeared:\n%s", out)
}
}
// TestBoundaryReportSaysNothingToCompareWhenEveryRunFailed: with no scored run
// the report must say so, rather than printing a comparison of zeros.
func TestBoundaryReportSaysNothingToCompareWhenEveryRunFailed(t *testing.T) {
all, _, _ := boundaryRuns(t)
failedOnly := make([]contextMetrics, 0, len(all))
for _, m := range all {
m.Failed = true
m.FailureStage = stageNoEvidence
failedOnly = append(failedOnly, m)
}
out := captureStdout(t, func() { reportBoundaries(failedOnly) })
if !strings.Contains(out, "scored=0") || !strings.Contains(out, "failed=") {
t.Fatalf("an all-failed batch did not report scored=0 with a failed count:\n%s", out)
}
if strings.Contains(out, "cue-present") || strings.Contains(out, "cue-absent") {
t.Fatalf("an all-failed batch still printed a pooled comparison of zeros:\n%s", out)
}
}
-171
View File
@@ -1,171 +0,0 @@
// capture.go — what the agent actually hands a provider.
package main
import (
"context"
"encoding/json"
"fmt"
"reasonix/internal/state/sessionstore"
"strings"
"reasonix/internal/assembly/boot"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/runtime/agent"
_ "reasonix/internal/tools/builtin"
)
// A projection is what the agent believes; a provider.Request is what the model
// is told. Rendering, sanitization and tool-schema construction sit between
// them, so the arm's contract is checked on the request.
type captureProvider struct {
requests []provider.Request
}
func (p *captureProvider) Name() string { return "contextbench-capture" }
// Stream records the request and answers with a clean terminal response. An
// empty answer would buy an empty-final retry and a second request, which is
// not the one round this is here to inspect.
func (p *captureProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.requests = append(p.requests, cloneRequest(req))
ch := make(chan provider.Chunk, 2)
ch <- provider.Chunk{Type: provider.ChunkText, Text: "contextbench preflight complete"}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
func cloneRequest(req provider.Request) provider.Request {
out := req
out.Messages = append([]provider.Message(nil), req.Messages...)
out.Tools = append([]provider.ToolSchema(nil), req.Tools...)
return out
}
// captureRequest rebuilds the session the way a resume does — transcript from
// disk, projection from its sidecar, the shipped tool surface at this arm —
// and returns the request the first round hands the provider.
func captureRequest(f builtFixture, arm ablation.Set, prompt string) (provider.Request, error) {
sess, err := sessionstore.LoadSession(f.Path)
if err != nil {
return provider.Request{}, fmt.Errorf("load session: %w", err)
}
reg := tool.NewRegistry()
for _, t := range tool.Builtins() {
reg.Add(t)
}
boot.ApplyUnifiedProviderToolSurface(reg, false, arm)
cap := &captureProvider{}
a := agent.New(cap, reg, sess, agent.Options{
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
SessionPath: f.Path, KeepPolicy: agent.KeepErrors, Ablation: arm,
}, event.Discard)
a.LoadProjectionSidecar(f.Path)
if err := a.Run(context.Background(), prompt); err != nil {
return provider.Request{}, fmt.Errorf("run: %w", err)
}
// The turn's own request is the one carrying tools. A fold on the way in
// asks the same provider for a digest, and that request has none — taking
// the first request blindly would inspect the summarizer's prompt.
for _, req := range cap.requests {
if len(req.Tools) > 0 {
return req, nil
}
}
return provider.Request{}, fmt.Errorf("no request carried tools (%d captured)", len(cap.requests))
}
// surfaceField is one place a model could read a string, named so a leak
// report says where rather than only that.
type surfaceField struct {
Path string
Text string
}
// providerSurface enumerates every provider-visible string in a request.
// Enumerated rather than marshalled whole: a Message also carries local-only
// fields, and scanning those would fail on bytes no model ever sees.
func providerSurface(req provider.Request) []surfaceField {
var out []surfaceField
add := func(path, text string) {
if strings.TrimSpace(text) != "" {
out = append(out, surfaceField{Path: path, Text: text})
}
}
for i, m := range req.Messages {
add(fmt.Sprintf("messages[%d].content", i), m.Content)
add(fmt.Sprintf("messages[%d].reasoning_content", i), m.ReasoningContent)
add(fmt.Sprintf("messages[%d].name", i), m.Name)
for j, tc := range m.ToolCalls {
add(fmt.Sprintf("messages[%d].tool_calls[%d].name", i, j), tc.Name)
add(fmt.Sprintf("messages[%d].tool_calls[%d].arguments", i, j), tc.Arguments)
}
for j, item := range m.ResponsesItems {
add(fmt.Sprintf("messages[%d].responses_items[%d]", i, j), string(item))
}
}
for i, t := range req.Tools {
add(fmt.Sprintf("tools[%d].name", i), t.Name)
add(fmt.Sprintf("tools[%d].description", i), t.Description)
if len(t.Parameters) > 0 {
b, err := json.Marshal(t.Parameters)
if err == nil {
add(fmt.Sprintf("tools[%d].parameters", i), string(b))
}
}
}
return out
}
// findInSurface reports every place a marker is readable.
func findInSurface(req provider.Request, marker string) []string {
var hits []string
for _, f := range providerSurface(req) {
if strings.Contains(f.Text, marker) {
hits = append(hits, f.Path)
}
}
return hits
}
// recallToolSchema returns the recall tool as the provider sees it.
func recallToolSchema(req provider.Request) (provider.ToolSchema, bool) {
for _, t := range req.Tools {
if t.Name == "recall" {
return t, true
}
}
return provider.ToolSchema{}, false
}
// localOnlyLeaks reports host-side fields that should never survive the
// provider boundary. A hit here is a boundary regression, marker or no marker.
func localOnlyLeaks(req provider.Request) []string {
var out []string
for i, m := range req.Messages {
switch {
case m.RawContent != "":
out = append(out, fmt.Sprintf("messages[%d].raw_content", i))
case m.LocalOnly:
out = append(out, fmt.Sprintf("messages[%d].local_only", i))
case m.DecisionReceipt != nil || len(m.DecisionReceipts) > 0:
out = append(out, fmt.Sprintf("messages[%d].decision_receipt", i))
case m.ToolExecution != nil:
out = append(out, fmt.Sprintf("messages[%d].tool_execution", i))
case len(m.MemoryCitations) > 0:
out = append(out, fmt.Sprintf("messages[%d].memory_citations", i))
}
}
return out
}
// searchWording is what a search-off arm must never be told. The arm exists to
// reproduce the runtime before search; an arm offered a capability it does not
// have is a different experiment.
var searchWording = []string{"recall with a query", "search the whole folded region", "query still finds"}
@@ -1,671 +0,0 @@
// contextbench.go — what the model did with the affordance, read off structure.
package main
import (
"encoding/json"
"fmt"
"io"
"regexp"
"slices"
"strconv"
"strings"
"reasonix/internal/base/tokencount"
"reasonix/internal/contract/provider"
)
// Every judgement here reads a tool call's name and its JSON arguments, or a
// canonical position, and never the model's prose. "I will search the history"
// is not a search; a recall call with a query is.
// failure stages, in funnel order. Exactly one is reported per run, except for
// NoEvidence: that stage belongs to a run with no measurement at all.
const (
stageReadMissed = "NoSearch" // never asked, and never read the target either
stageSearchMiss = "SearchMiss" // searched, target never came back
stageHitNotRead = "HitNotRead" // target was returned and never opened
stageAnswerWrong = "ReadButAnswerWrong" // opened it and still answered wrong
stageRecovered = "Recovered"
stageContaminated = "Contaminated"
stageNoEvidence = "NoEvidence"
// Routing is judged by model round, not by the linear order of tool events:
// two calls in one round are one parallel fan-out, and calling that
// "workspace first" would report a choice the model never made.
routeMemoryFirst = "MemoryFirst"
routeWorkspaceFirst = "WorkspaceFirst"
routeParallel = "Parallel"
routeNeither = "Neither"
stageCueRead = "DirectRead" // read the target from an index address, no search needed
stageNoRetrieval = "NoRetrieval" // answered without touching recall at all
toolRecall = "recall"
)
// searchAttempt is one query the model wrote, and what it got back. Kept per
// attempt rather than aggregated: the question is why a retrieval that the host
// probe ranks first takes the model more than one try.
type searchAttempt struct {
Ordinal int `json:"ordinal"`
Round int `json:"round"`
Query string `json:"query"`
TargetRank int `json:"target_rank"` // 0 = the target did not come back
ResultCount int `json:"result_count"`
// Reformulation classifies this query against the one before it, and
// MissingProbeTerms are the host probe's terms this query left out. Both
// are derived from token sets: no judge, and nothing read out of prose.
Reformulation string `json:"reformulation,omitempty"`
MissingProbeTerms []string `json:"missing_probe_terms,omitempty"`
}
// contextMetrics is one run's behaviour.
type contextMetrics struct {
Task string `json:"task"`
Arm string `json:"arm"`
SearchCalls int `json:"search_calls"`
// SearchHits counts searches that returned anything; TargetSearchHits
// counts searches that returned the target. The gap is query quality.
SearchHits int `json:"search_hits"`
TargetSearchHits int `json:"target_search_hits"`
FirstTargetRank int `json:"first_target_rank"` // 0 = never returned
// FirstHitCoverage: how much of the answer the first hit handed over.
// rank=1 with coverage 1/3 is a window too short to answer with, not a
// model that would not stop.
FirstHitCoverage int `json:"first_hit_coverage"`
AnswerValues int `json:"answer_values"`
ReadCalls int `json:"read_calls"`
TargetRead bool `json:"target_read"`
ReadAfterHit bool `json:"read_after_hit"`
// RecallReadWithoutSearch: the index addressed the target, so no search was
// needed. Not "direct read" — read_file is a read too, and in an audit the
// two mean opposite things.
RecallReadWithoutSearch bool `json:"recall_read_without_search"`
SearchThenRecallRead bool `json:"search_then_recall_read"`
FirstSearchRound int `json:"first_search_round"`
FirstReadRound int `json:"first_read_round"`
RetrievalRounds int `json:"retrieval_rounds"`
// RecallReturnedTokens is what recall paged back in. The index saves
// resident prefix; this is what is paid dynamically instead.
RecallReturnedTokens int `json:"recall_returned_tokens"`
// Provider usage as reported on the stream, or empty for a dry run. The count
// is completions that reported usage, so it is not a count of attempts.
UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"`
CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
UsageEstimated bool `json:"usage_estimated,omitempty"`
Searches []searchAttempt `json:"searches,omitempty"`
// PostHitSearches are queries issued after the target had already been
// returned: not a query problem, a result-to-read problem.
PostHitSearches int `json:"post_hit_searches"`
AnswerRecovered bool `json:"answer_recovered"`
// AnswerAmbiguous marks an answer that offered more than one candidate for
// a value. Markers matching proves the right fact appears; it does not
// prove it was submitted as the answer. Recorded, never acted on.
AnswerAmbiguous bool `json:"answer_ambiguous,omitempty"`
AmbiguousVar string `json:"ambiguous_var,omitempty"`
MissingMarkers []string `json:"missing_markers,omitempty"`
// FinalAnswer is kept bounded so a surprising run can be read back. A
// metric nobody can audit is a claim, not a measurement.
FinalAnswer string `json:"final_answer,omitempty"`
FailureStage string `json:"failure_stage"`
TimeoutReason string `json:"timeout_reason,omitempty"`
// Failed marks a run that ended before a transcript existed: it has no score
// and no place in the statistics. The row stays in results.jsonl with its
// task, arm and stage, so a failure is counted rather than dropped.
Failed bool `json:"failed,omitempty"`
Reason string `json:"reason,omitempty"`
// UnexpectedWorkTools are investigation tools used instead of recall.
// Reaching for the workspace is behaviour, not noise: how a model routes
// its uncertainty when memory is missing is part of what is measured.
UnexpectedWorkTools []string `json:"unexpected_work_tools,omitempty"`
EscapeCalls int `json:"escape_calls"`
EscapeBeforeFirstRecall int `json:"escape_before_first_recall"`
EscapeAfterFirstRecall int `json:"escape_after_first_recall"`
// Contaminated: the answer was reachable through something other than
// recall. Asserting this beats trusting the isolation, and a run that trips
// it leaves the statistics rather than inflating one.
Contaminated bool `json:"contaminated,omitempty"`
LeakedVia string `json:"leaked_via,omitempty"`
LeakedMarker string `json:"leaked_marker,omitempty"`
LeakedArgs string `json:"leaked_args,omitempty"`
LeakedOutput string `json:"leaked_output,omitempty"`
// EscapeFoundAnswer separates "looked at the workspace" from "found the
// answer there": different facts about the same call.
EscapeFoundAnswer bool `json:"escape_found_answer,omitempty"`
CueVisible bool `json:"cue_visible"`
// trajectory is kept out of the metric line and written beside it, so a
// future question can be asked of this batch instead of a new one.
trajectory []provider.Message
// CueDirectRead: the cue was addressed, the first memory action was a
// positions read, and it covered the target. Crediting any positions call
// would hand the index a search's work.
CueDirectRead bool `json:"cue_direct_read"`
// Sufficiency, not hit. A search that returns the target at rank 1 has not
// necessarily returned the answer: the snippet is bounded. The stopping
// boundary is the round where everything needed was readable.
FirstSufficientRound int `json:"first_sufficient_round"`
SufficientVia string `json:"sufficient_via"` // snippet | read | never
RoundsAfterSufficient int `json:"rounds_after_sufficient"`
SearchesAfterSufficient int `json:"searches_after_sufficient"`
ReadsAfterSufficient int `json:"reads_after_sufficient"`
EscapesAfterSufficient int `json:"escapes_after_sufficient"`
RecallTokensAfterSufficient int `json:"recall_tokens_after_sufficient"`
StoppingClass string `json:"stopping_class"`
// What happened after the answer was already in hand. A timeout that spent
// seven rounds past its target is a stopping problem, and extending the
// budget would only buy the loop more time.
TargetFirstSeenRound int `json:"target_first_seen_round"`
RoundsAfterTarget int `json:"rounds_after_target"`
SearchesAfterTarget int `json:"searches_after_target"`
RecallTokensAfterTarget int `json:"recall_tokens_after_target"`
TargetHeldAtEnd bool `json:"target_held_at_end"`
// Routing is where the model looked first when its memory was missing.
Routing string `json:"routing"`
FirstRecallRound int `json:"first_recall_round"`
FirstEscapeRound int `json:"first_escape_round"`
}
// hitPosition matches the address recall prints for a search hit. Reading our
// own rendered format, not guessing at the model's words.
var hitPosition = regexp.MustCompile(`(?m)^#(\d+) `)
// scoreRun reads the messages one turn appended and reports what happened.
// runScorer walks one turn's messages, holding the state a judgement needs:
// which calls are open, whether the target has come back, and which search
// each result belongs to.
type runScorer struct {
m contextMetrics
inst fixtureInstance
target int
calls map[string]provider.ToolCall
pending map[string]int
round int
sawTargetHit bool
// seen accumulates everything recall has shown the model, so sufficiency is
// judged on what it could read rather than on which call produced it.
seen strings.Builder
sufficient bool
}
// scoreRun reads the messages one turn appended and reports what happened.
func scoreRun(msgs []provider.Message, inst fixtureInstance, target int, arm string, cueVisible bool) contextMetrics {
s := &runScorer{
m: contextMetrics{Task: inst.Task.ID, Arm: arm, CueVisible: cueVisible},
inst: inst, target: target,
calls: map[string]provider.ToolCall{}, pending: map[string]int{},
}
for _, msg := range msgs {
if len(msg.ToolCalls) > 0 {
s.round++
}
for _, tc := range msg.ToolCalls {
s.calls[tc.ID] = tc
s.observeCall(tc)
}
if msg.Role == provider.RoleTool {
s.observeResult(msg)
}
}
return s.finish(cueVisible)
}
// observeCall records one tool call: a recall search, a recall read, or a
// reach for the workspace.
func (s *runScorer) observeCall(tc provider.ToolCall) {
m := &s.m
if tc.Name != toolRecall {
if !containsString(m.UnexpectedWorkTools, tc.Name) {
m.UnexpectedWorkTools = append(m.UnexpectedWorkTools, tc.Name)
}
m.EscapeCalls++
if s.sufficient {
m.EscapesAfterSufficient++
}
if m.FirstEscapeRound == 0 {
m.FirstEscapeRound = s.round
}
if m.SearchCalls+m.ReadCalls == 0 {
m.EscapeBeforeFirstRecall++
} else {
m.EscapeAfterFirstRecall++
}
return
}
if m.FirstRecallRound == 0 {
m.FirstRecallRound = s.round
}
query, positions := recallArgs(tc.Arguments)
switch {
case query != "":
m.SearchCalls++
if m.FirstSearchRound == 0 {
m.FirstSearchRound = s.round
}
if s.sawTargetHit {
m.PostHitSearches++
m.SearchesAfterTarget++
}
if s.sufficient {
m.SearchesAfterSufficient++
}
m.Searches = append(m.Searches, searchAttempt{Ordinal: m.SearchCalls, Round: s.round, Query: query})
s.pending[tc.ID] = len(m.Searches) - 1
case len(positions) > 0:
m.ReadCalls++
if s.sufficient {
m.ReadsAfterSufficient++
}
if m.FirstReadRound == 0 {
m.FirstReadRound = s.round
}
// A read covers the target when it names its position: recall returns
// the tool results under the caller's own address.
if containsInt(positions, s.target) {
m.TargetRead = true
switch {
case s.sawTargetHit:
m.ReadAfterHit, m.SearchThenRecallRead = true, true
case m.SearchCalls == 0:
m.RecallReadWithoutSearch = true
}
}
}
}
// observeResult reads what a call returned: contamination, page-in cost, and
// whether this search brought the target back.
func (s *runScorer) observeResult(msg provider.Message) {
m := &s.m
call, ok := s.calls[msg.ToolCallID]
if !ok {
return
}
if call.Name != toolRecall {
// Codenames only: a three-digit epoch appears in any long file by
// chance, and one did. A model holding the codename holds the numbers
// beside it anyway.
for _, marker := range s.inst.AnswerMarkers {
if len(marker) >= 6 && strings.Contains(msg.Content, marker) {
m.Contaminated, m.LeakedVia, m.LeakedMarker = true, call.Name, marker
m.EscapeFoundAnswer = true
m.LeakedArgs, m.LeakedOutput = truncate(call.Arguments, 200), truncate(msg.Content, 300)
}
}
return
}
cost := tokencount.Text(msg.Content)
m.RecallReturnedTokens += cost
if s.sawTargetHit {
m.RecallTokensAfterTarget += cost
}
if s.sufficient {
m.RecallTokensAfterSufficient += cost
}
// Everything recall showed the model, judged as a whole: the answer may
// arrive across a snippet and a read, and either way it is readable.
s.seen.WriteString(msg.Content)
s.seen.WriteString("\n")
if !s.sufficient && allMarkersIn(s.seen.String(), s.inst.AnswerMarkers) {
s.sufficient = true
m.FirstSufficientRound = s.round
query, _ := recallArgs(call.Arguments)
m.SufficientVia = "read"
if query != "" {
m.SufficientVia = "snippet"
}
}
if query, _ := recallArgs(call.Arguments); query == "" {
return
}
positions := searchHitPositions(msg.Content)
if len(positions) > 0 {
m.SearchHits++
}
rank := indexOfInt(positions, s.target)
if i, ok := s.pending[msg.ToolCallID]; ok {
m.Searches[i].TargetRank, m.Searches[i].ResultCount = rank, len(positions)
}
if rank == 0 {
return
}
m.TargetSearchHits++
if !s.sawTargetHit {
m.TargetFirstSeenRound = s.round
m.FirstHitCoverage = answerCoverage(msg.Content, s.inst.AnswerMarkers)
}
s.sawTargetHit = true
if m.FirstTargetRank == 0 {
m.FirstTargetRank = rank
}
}
// finish derives what only the whole walk can say.
func (s *runScorer) finish(cueVisible bool) contextMetrics {
m := s.m
m.RetrievalRounds = m.SearchCalls + m.ReadCalls
m.TargetHeldAtEnd = s.sawTargetHit || m.TargetRead
if m.TargetFirstSeenRound > 0 {
m.RoundsAfterTarget = s.round - m.TargetFirstSeenRound
}
m.Routing = routing(m.FirstRecallRound, m.FirstEscapeRound)
m.CueDirectRead = cueVisible && m.RecallReadWithoutSearch
if m.SufficientVia == "" {
m.SufficientVia = "never"
}
if m.FirstSufficientRound > 0 {
m.RoundsAfterSufficient = s.round - m.FirstSufficientRound
}
m.AnswerValues = len(s.inst.AnswerMarkers)
m.StoppingClass = stoppingClass(m)
classifySearches(&m, s.inst)
return m
}
// scoreAnswer grades the final text on the task's markers. Nonces, matched
// exactly: no judge model, and nothing a general answer could satisfy.
func (m *contextMetrics) scoreAnswer(final string, inst fixtureInstance) {
lower := strings.ToLower(final)
for _, marker := range inst.AnswerMarkers {
if !strings.Contains(lower, strings.ToLower(marker)) {
m.MissingMarkers = append(m.MissingMarkers, marker)
}
}
m.AnswerRecovered = len(m.MissingMarkers) == 0
m.AnswerAmbiguous, m.AmbiguousVar = ambiguousAnswer(final, inst)
m.FailureStage = m.stage()
if len(final) > 1200 {
final = final[:1200] + "…"
}
m.FinalAnswer = final
}
// stage places the run in the funnel. One label, chosen at the first step that
// did not happen, so a table of stages reads as where the retrieval broke.
func (m contextMetrics) stage() string {
switch {
case m.Contaminated:
return stageContaminated
case m.AnswerRecovered && m.RecallReadWithoutSearch:
return stageCueRead
case m.AnswerRecovered:
return stageRecovered
case m.TargetRead:
return stageAnswerWrong
case m.TargetSearchHits > 0:
return stageHitNotRead
case m.SearchCalls > 0:
return stageSearchMiss
case m.ReadCalls > 0:
return stageReadMissed
default:
return stageNoRetrieval
}
}
// recallArgs pulls the two fields that say which operation a recall call is.
func recallArgs(args string) (query string, positions []int) {
var parsed struct {
Query string `json:"query"`
Positions []int `json:"positions"`
}
if err := json.Unmarshal([]byte(args), &parsed); err != nil {
return "", nil
}
return strings.TrimSpace(parsed.Query), parsed.Positions
}
// searchHitPositions reads the addresses out of a search result, in rank order.
func searchHitPositions(text string) []int {
var out []int
for _, match := range hitPosition.FindAllStringSubmatch(text, -1) {
if n, err := strconv.Atoi(match[1]); err == nil {
out = append(out, n)
}
}
return out
}
func containsInt(list []int, want int) bool { return slices.Contains(list, want) }
// indexOfInt returns a 1-based rank, or 0 when absent.
func indexOfInt(list []int, want int) int {
for i, v := range list {
if v == want {
return i + 1
}
}
return 0
}
func containsString(list []string, want string) bool { return slices.Contains(list, want) }
// funnel summarizes one arm's runs the way the experiment is read.
type funnel struct {
Arm string
Runs int
Scored, Contaminated, Errored int
Searched, TargetHit, TargetRead, Answered int
CueReads int
SearchCalls, ReadCalls, RecallTokens int
Escapes, EscapeCalls int
Stages, Routes map[string]int
}
func summarize(arm string, runs []contextMetrics) funnel {
f := funnel{Arm: arm, Runs: len(runs), Stages: map[string]int{}, Routes: map[string]int{}}
for _, r := range runs {
// A run that never produced a transcript is not a scored run and has no
// stage to place: counting it would put an empty name in the funnel, the
// stage table and the routes.
if r.Failed {
continue
}
if r.Contaminated {
f.Contaminated++
f.Stages[r.FailureStage]++
continue
}
if r.FailureStage == "RunError" {
f.Errored++
f.Stages[r.FailureStage]++
continue
}
f.Scored++
if r.SearchCalls > 0 {
f.Searched++
}
if r.TargetSearchHits > 0 {
f.TargetHit++
}
if r.TargetRead {
f.TargetRead++
}
if r.AnswerRecovered {
f.Answered++
}
if r.RecallReadWithoutSearch {
f.CueReads++
}
if len(r.UnexpectedWorkTools) > 0 {
f.Escapes++
}
f.EscapeCalls += r.EscapeCalls
f.SearchCalls += r.SearchCalls
f.ReadCalls += r.ReadCalls
f.RecallTokens += r.RecallReturnedTokens
f.Stages[r.FailureStage]++
if r.FailureStage != "RunError" {
f.Routes[r.Routing]++
}
}
return f
}
func (f funnel) line() string {
per := func(n int) float64 {
if f.Scored == 0 {
return 0
}
return float64(n) / float64(f.Scored)
}
line := fmt.Sprintf("%-14s runs=%-3d answered=%d/%d searched=%d target-hit=%d cue-read=%d search/task=%.2f recall-tok/task=%.0f escape/task=%.2f",
f.Arm, f.Scored, f.Answered, f.Scored, f.Searched, f.TargetHit, f.CueReads,
per(f.SearchCalls), per(f.RecallTokens), per(f.EscapeCalls))
if f.Contaminated > 0 {
line += fmt.Sprintf(" CONTAMINATED=%d", f.Contaminated)
}
if f.Errored > 0 {
line += fmt.Sprintf(" ERRORED=%d", f.Errored)
}
return line
}
func writeJSONLine(w io.Writer, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
_, err = fmt.Fprintln(w, string(b))
return err
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// routing says where the model looked first, comparing rounds rather than
// event order. Same round is a parallel fan-out, not a preference.
func routing(recallRound, escapeRound int) string {
switch {
case recallRound == 0 && escapeRound == 0:
return routeNeither
case recallRound == 0:
return routeWorkspaceFirst
case escapeRound == 0:
return routeMemoryFirst
case recallRound < escapeRound:
return routeMemoryFirst
case escapeRound < recallRound:
return routeWorkspaceFirst
default:
return routeParallel
}
}
// ambiguousAnswer reports an answer naming more than one candidate of a
// codename's shape. Only codenames are checked: they carry a stem this run
// declared, so two distinct "fence-…" values in one answer is one answer too
// many. Numbers have no such shape and are left alone.
func ambiguousAnswer(final string, inst fixtureInstance) (bool, string) {
for _, spec := range inst.Task.Vars {
if spec.Kind != varCodename {
continue
}
seen := map[string]bool{}
for _, match := range regexp.MustCompile(regexp.QuoteMeta(spec.Word)+`-[a-z0-9]{3,}`).FindAllString(final, -1) {
seen[match] = true
}
if len(seen) > 1 {
return true, spec.Name
}
}
return false, ""
}
func allMarkersIn(text string, markers []string) bool {
if len(markers) == 0 {
return false
}
lower := strings.ToLower(text)
for _, marker := range markers {
if !strings.Contains(lower, strings.ToLower(marker)) {
return false
}
}
return true
}
// Stopping classes. The boundary is sufficiency, not the target coming back:
// a rank-1 hit whose snippet was too short to answer leaves a read entirely
// justified, and counting that as waste would blame the model for the bound.
const (
stopSnippet = "SnippetStop" // enough arrived, and the turn ended
stopSnippetRead = "SnippetThenRead" // the snippet was short; the read finished it
stopDefensiveRead = "DefensiveRead" // enough had arrived, one read confirmed it
stopPostRetrieval = "PostSufficientRetrieval" // enough had arrived, and retrieval continued
stopRunaway = "PostSufficientRunaway" // it continued for rounds, or ran out of budget
stopNeverEnough = "NeverSufficient"
)
// runawayRounds is where continuing stops looking like confirmation.
const runawayRounds = 3
func stoppingClass(m contextMetrics) string {
if m.FirstSufficientRound == 0 {
return stopNeverEnough
}
after := m.SearchesAfterSufficient + m.ReadsAfterSufficient + m.EscapesAfterSufficient
switch {
case after == 0 && m.SufficientVia == "snippet":
return stopSnippet
case after == 0:
return stopSnippetRead
case m.RoundsAfterSufficient >= runawayRounds || m.FailureStage == "RunError":
return stopRunaway
case after == 1 && m.ReadsAfterSufficient == 1:
return stopDefensiveRead
default:
return stopPostRetrieval
}
}
// reportStopping is the funnel that matters once retrieval succeeds: what the
// turn did after it already had everything it needed.
func reportStopping(all []contextMetrics) string {
classes := map[string]int{}
var extraRounds, extraTokens []float64
sufficient, answeredNext := 0, 0
for _, m := range all {
if m.Failed || m.Contaminated {
continue
}
classes[m.StoppingClass]++
if m.FirstSufficientRound == 0 {
continue
}
sufficient++
extraRounds = append(extraRounds, float64(m.RoundsAfterSufficient))
extraTokens = append(extraTokens, float64(m.RecallTokensAfterSufficient))
if m.RoundsAfterSufficient <= 1 {
answeredNext++
}
}
var b strings.Builder
b.WriteString("\n## After the evidence was already enough\n")
fmt.Fprintf(&b, " sufficient in %d runs; ended within one round in %d\n", sufficient, answeredNext)
fmt.Fprintf(&b, " extra rounds mean %.1f median %.1f p90 %.0f max %.0f\n",
mean(extraRounds), median(extraRounds), percentile(extraRounds, 0.9), maxOf(extraRounds))
fmt.Fprintf(&b, " extra recall tokens mean %.0f median %.0f p90 %.0f max %.0f\n",
mean(extraTokens), median(extraTokens), percentile(extraTokens, 0.9), maxOf(extraTokens))
b.WriteString(" " + countsLine(classes) + "\n")
return b.String()
}
@@ -1,337 +0,0 @@
package main
import (
"fmt"
"strings"
"testing"
"reasonix/internal/contract/provider"
)
// The scorer decides what the pilot concludes, so it is held to the same rule
// the corpus is: judgements read structure. A model that says it will search
// has not searched; a recall call carrying a query has.
const scorerTarget = 42
func recallCall(id, args string) provider.Message {
return provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: id, Name: "recall", Arguments: args},
}}
}
func toolResult(id, text string) provider.Message {
return provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "recall", Content: text}
}
func hitBlock(positions ...int) string {
var b strings.Builder
b.WriteString("Folded context matching \"x\":\n")
for _, p := range positions {
fmt.Fprintf(&b, "\n#%d assistant_text\nsome snippet here\n", p)
}
return b.String()
}
func scorerTask() fixtureInstance {
return fixtureInstance{Task: contextTask{ID: "t"}, AnswerMarkers: []string{"cobalt-lark-17"}}
}
func score(t *testing.T, msgs []provider.Message, final string) contextMetrics {
t.Helper()
m := scoreRun(msgs, scorerTask(), scorerTarget, "arm", false)
m.scoreAnswer(final, scorerTask())
return m
}
// The whole funnel, walked end to end.
func TestScorerReadsTheFullRetrievalChain(t *testing.T) {
m := score(t, []provider.Message{
recallCall("c1", `{"query":"retry boundary"}`),
toolResult("c1", hitBlock(7, scorerTarget)),
recallCall("c2", fmt.Sprintf(`{"positions":[%d]}`, scorerTarget)),
toolResult("c2", "#42\n[assistant]\nthe ownership token is cobalt-lark-17"),
{Role: provider.RoleAssistant, Content: "The token was cobalt-lark-17."},
}, "The token was cobalt-lark-17.")
if m.SearchCalls != 1 || m.ReadCalls != 1 {
t.Errorf("calls = %d search / %d read, want 1/1", m.SearchCalls, m.ReadCalls)
}
if m.TargetSearchHits != 1 || m.FirstTargetRank != 2 {
t.Errorf("target hit %d at rank %d, want 1 at rank 2", m.TargetSearchHits, m.FirstTargetRank)
}
if !m.TargetRead || !m.ReadAfterHit || m.RecallReadWithoutSearch {
t.Errorf("read flags = read %v afterHit %v direct %v", m.TargetRead, m.ReadAfterHit, m.RecallReadWithoutSearch)
}
if !m.AnswerRecovered || m.FailureStage != stageRecovered {
t.Errorf("stage = %q recovered=%v, want Recovered", m.FailureStage, m.AnswerRecovered)
}
if m.RecallReturnedTokens == 0 {
t.Error("recall returned tokens were not counted")
}
}
// An index arm's claim: the address was already on screen, so no search was
// needed. That is a different outcome from finding it, and gets its own stage.
func TestScorerSeparatesCueReadFromSearch(t *testing.T) {
m := score(t, []provider.Message{
recallCall("c1", fmt.Sprintf(`{"positions":[%d]}`, scorerTarget)),
toolResult("c1", "#42\nthe ownership token is cobalt-lark-17"),
{Role: provider.RoleAssistant, Content: "cobalt-lark-17"},
}, "cobalt-lark-17")
if m.SearchCalls != 0 || !m.RecallReadWithoutSearch {
t.Errorf("search=%d direct=%v, want 0 and true", m.SearchCalls, m.RecallReadWithoutSearch)
}
if m.FailureStage != stageCueRead {
t.Errorf("stage = %q, want %q", m.FailureStage, stageCueRead)
}
}
// Every way the chain can break gets its own label, so a failed run says where
// rather than only that.
func TestScorerNamesWhereTheChainBroke(t *testing.T) {
for _, tc := range []struct {
name string
msgs []provider.Message
final string
want string
}{
{
name: "never touched recall",
msgs: []provider.Message{{Role: provider.RoleAssistant, Content: "I do not recall that."}},
final: "I do not recall that.",
want: stageNoRetrieval,
},
{
name: "searched and missed",
msgs: []provider.Message{
recallCall("c1", `{"query":"unrelated words"}`),
toolResult("c1", hitBlock(3, 9)),
},
final: "not found",
want: stageSearchMiss,
},
{
name: "found it and never opened it",
msgs: []provider.Message{
recallCall("c1", `{"query":"retry boundary"}`),
toolResult("c1", hitBlock(scorerTarget)),
},
final: "probably something about retries",
want: stageHitNotRead,
},
{
name: "read it and answered wrong",
msgs: []provider.Message{
recallCall("c1", fmt.Sprintf(`{"positions":[%d]}`, scorerTarget)),
toolResult("c1", "#42\nthe token is elsewhere"),
},
final: "I think it was amber-9",
want: stageAnswerWrong,
},
{
name: "read the wrong position",
msgs: []provider.Message{
recallCall("c1", `{"positions":[7]}`),
toolResult("c1", "#7\nnothing useful"),
},
final: "unclear",
want: stageReadMissed,
},
} {
t.Run(tc.name, func(t *testing.T) {
if got := score(t, tc.msgs, tc.final).FailureStage; got != tc.want {
t.Errorf("stage = %q, want %q", got, tc.want)
}
})
}
}
// The answer lives only in folded history, so reaching for the workspace is a
// strategy change worth seeing — including when it happens to work.
func TestScorerRecordsWorkspaceEscapes(t *testing.T) {
m := score(t, []provider.Message{
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "g1", Name: "bash", Arguments: `{"command":"rg cobalt-lark"}`},
{ID: "g2", Name: "read_file", Arguments: `{"path":"notes.md"}`},
}},
{Role: provider.RoleAssistant, Content: "cobalt-lark-17"},
}, "cobalt-lark-17")
if len(m.UnexpectedWorkTools) != 2 {
t.Fatalf("escapes = %v, want bash and read_file", m.UnexpectedWorkTools)
}
if m.SearchCalls != 0 || m.ReadCalls != 0 {
t.Errorf("a workspace tool was counted as recall: search=%d read=%d", m.SearchCalls, m.ReadCalls)
}
// It answered, so the run is Recovered — the escape is reported beside the
// outcome rather than overriding it.
if !m.AnswerRecovered {
t.Error("a correct answer was not scored as recovered")
}
}
// Narration is not action.
func TestScorerIgnoresWhatTheModelSaysItWillDo(t *testing.T) {
m := score(t, []provider.Message{
{Role: provider.RoleAssistant, Content: "Let me search the folded context with recall for the retry boundary."},
}, "Let me search the folded context with recall for the retry boundary.")
if m.SearchCalls != 0 || m.FailureStage != stageNoRetrieval {
t.Errorf("prose was scored as a search: calls=%d stage=%q", m.SearchCalls, m.FailureStage)
}
}
// Every marker must land, so a partially right answer is not a recovery.
func TestScorerRequiresEveryMarker(t *testing.T) {
task := fixtureInstance{Task: contextTask{ID: "t"}, AnswerMarkers: []string{"comet-42", "per-lineage"}}
m := scoreRun(nil, task, scorerTarget, "arm", false)
m.scoreAnswer("the salt was comet-42", task)
if m.AnswerRecovered {
t.Error("a half answer scored as recovered")
}
if len(m.MissingMarkers) != 1 || m.MissingMarkers[0] != "per-lineage" {
t.Errorf("missing = %v, want per-lineage", m.MissingMarkers)
}
}
// An answer reachable through anything but recall invalidates the run. The
// workspace is isolated so this should never fire; asserting it beats trusting
// it, and a tripped run leaves the statistics rather than inflating one.
func TestScorerInvalidatesRunsThatLeakedThroughAnotherTool(t *testing.T) {
m := score(t, []provider.Message{
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "g1", Name: "bash", Arguments: `{"command":"rg -r ."}`},
}},
{Role: provider.RoleTool, ToolCallID: "g1", Name: "bash", Content: "corpus.go: the token is cobalt-lark-17"},
{Role: provider.RoleAssistant, Content: "cobalt-lark-17"},
}, "cobalt-lark-17")
if !m.Contaminated || m.LeakedVia != "bash" || m.LeakedMarker != "cobalt-lark-17" {
t.Fatalf("leak not caught: contaminated=%v via=%q marker=%q", m.Contaminated, m.LeakedVia, m.LeakedMarker)
}
if m.FailureStage != stageContaminated {
t.Errorf("stage = %q, want %q even though every marker matched", m.FailureStage, stageContaminated)
}
f := summarize("arm", []contextMetrics{m})
if f.Scored != 0 || f.Answered != 0 || f.Contaminated != 1 {
t.Errorf("a contaminated run entered the statistics: %+v", f)
}
}
// Where an escape happened matters: before any recall is a different strategy
// from after one came back short.
func TestScorerPlacesEscapesRelativeToRecall(t *testing.T) {
m := score(t, []provider.Message{
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "g1", Name: "bash", Arguments: `{"command":"ls"}`},
}},
{Role: provider.RoleTool, ToolCallID: "g1", Name: "bash", Content: "README.md"},
recallCall("c1", `{"query":"retry boundary"}`),
toolResult("c1", hitBlock(scorerTarget)),
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "g2", Name: "read_file", Arguments: `{"path":"README.md"}`},
}},
{Role: provider.RoleTool, ToolCallID: "g2", Name: "read_file", Content: "nothing here"},
}, "unclear")
if m.EscapeCalls != 2 || m.EscapeBeforeFirstRecall != 1 || m.EscapeAfterFirstRecall != 1 {
t.Errorf("escapes = %d (%d before, %d after), want 2 (1, 1)",
m.EscapeCalls, m.EscapeBeforeFirstRecall, m.EscapeAfterFirstRecall)
}
}
// Routing is judged by round. Two calls in one round are a fan-out, and
// reporting that as a preference would invent a decision the model never made.
func TestRoutingIsJudgedByRoundNotEventOrder(t *testing.T) {
parallel := []provider.Message{
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "g1", Name: "bash", Arguments: `{"command":"ls"}`},
{ID: "c1", Name: "recall", Arguments: `{"query":"retry boundary"}`},
}},
{Role: provider.RoleTool, ToolCallID: "g1", Name: "bash", Content: "README.md"},
toolResult("c1", hitBlock(scorerTarget)),
}
if got := score(t, parallel, "unclear").Routing; got != routeParallel {
t.Errorf("routing = %q for one round holding both, want %q", got, routeParallel)
}
workspaceFirst := []provider.Message{
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "g1", Name: "bash", Arguments: `{"command":"ls"}`},
}},
{Role: provider.RoleTool, ToolCallID: "g1", Name: "bash", Content: "README.md"},
recallCall("c1", `{"query":"retry boundary"}`),
toolResult("c1", hitBlock(scorerTarget)),
}
if got := score(t, workspaceFirst, "unclear").Routing; got != routeWorkspaceFirst {
t.Errorf("routing = %q for workspace in an earlier round, want %q", got, routeWorkspaceFirst)
}
memoryFirst := []provider.Message{
recallCall("c1", `{"query":"retry boundary"}`),
toolResult("c1", hitBlock(scorerTarget)),
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "g1", Name: "bash", Arguments: `{"command":"ls"}`},
}},
{Role: provider.RoleTool, ToolCallID: "g1", Name: "bash", Content: "README.md"},
}
if got := score(t, memoryFirst, "unclear").Routing; got != routeMemoryFirst {
t.Errorf("routing = %q for recall in an earlier round, want %q", got, routeMemoryFirst)
}
if got := score(t, nil, "no idea").Routing; got != routeNeither {
t.Errorf("routing = %q with no tools at all, want %q", got, routeNeither)
}
}
// Markers matching proves the right fact appears somewhere. An answer that
// hedges between two candidates contains it and has not submitted it.
func TestScorerFlagsAnAnswerThatNamesTwoCandidates(t *testing.T) {
inst := fixtureInstance{
Task: contextTask{ID: "t", Vars: []varSpec{code("marker", "fence")}},
AnswerMarkers: []string{"fence-qmwnv"},
}
m := scoreRun(nil, inst, 0, "arm", false)
m.scoreAnswer("It was fence-qmwnv, though it may have been fence-abcde.", inst)
if !m.AnswerRecovered {
t.Error("the marker is present; recovery should still read true")
}
if !m.AnswerAmbiguous || m.AmbiguousVar != "marker" {
t.Errorf("ambiguity not flagged: %v %q", m.AnswerAmbiguous, m.AmbiguousVar)
}
clean := scoreRun(nil, inst, 0, "arm", false)
clean.scoreAnswer("The marker was fence-qmwnv.", inst)
if clean.AnswerAmbiguous {
t.Error("a single candidate was called ambiguous")
}
}
// The index arm's claim is narrow: the cue was addressed, the first memory
// action was a positions read, and it covered the target. A positions call
// after a search located the target credits the index for the search's work.
func TestCueDirectReadRequiresTheCueAndTheFirstAction(t *testing.T) {
direct := []provider.Message{
recallCall("c1", fmt.Sprintf(`{"positions":[%d]}`, scorerTarget)),
toolResult("c1", "#42\nthe token is cobalt-lark-17"),
}
m := scoreRun(direct, scorerTask(), scorerTarget, "arm", true)
if !m.CueDirectRead {
t.Error("a first-action read with the cue visible was not credited")
}
// Same read, but no cue was on screen to give the address.
if scoreRun(direct, scorerTask(), scorerTarget, "arm", false).CueDirectRead {
t.Error("a read was credited to a cue that was not visible")
}
// Same read, but a search found the address first.
afterSearch := append([]provider.Message{
recallCall("c0", `{"query":"retry boundary"}`),
toolResult("c0", hitBlock(scorerTarget)),
}, direct...)
if scoreRun(afterSearch, scorerTask(), scorerTarget, "arm", true).CueDirectRead {
t.Error("a read after a search was credited to the index")
}
}
-469
View File
@@ -1,469 +0,0 @@
package main
import (
"fmt"
"math/rand"
"reasonix/internal/state/sessionstore"
"reasonix/internal/contract/provider"
)
// Synthetic history on purpose: an answer that exists in the workspace is
// solvable by reading the workspace, which measures grep. Every answer is a
// placeholder here and a value only inside one run.
// experiment names which question a task belongs to.
const (
experimentSearch = "search" // does search reach what no index addresses?
experimentIndex = "index" // what is an ambient address line worth?
)
// cueTier is the narrowest fold-index scale at which an index task's cue is
// still addressed. Below it the model has to find the call without a hint.
const (
tierQuarter = "quarter" // visible at default, half, quarter
tierHalf = "half" // visible at default, half
tierDefault = "default" // visible at default only
)
// role places an index task in the efficiency substrate or the stopping corpus.
const (
roleEfficiency = "efficiency"
roleStopping = "stopping"
)
// plantKind is how a task's target enters the transcript.
const (
plantAssistant = "assistant" // the model's own account: never index-addressed
plantCall = "call" // a tool call and its result: always index-addressed
)
// contextTask is one recall question as a template. Nothing here is an answer.
type contextTask struct {
ID string
Experiment string
TargetKind string
Prompt string
ProbeQuery string
// Vars declare the placeholders. AnswerVars name the ones a correct answer
// must contain, CueVar the one an index line carries.
Vars []varSpec
AnswerVars []string
CueVar string
CueTier string
// Role is roleEfficiency or roleStopping; it decides the batch a task enters.
Role string
// PlantAfterGen ages the target: mergeFoldIndex trims from its oldest end,
// so what follows a cue decides which budget addresses it. Frozen, because
// a run that searched for it would let the tested code move its goalposts.
PlantAfterGen int
// Plant is how the target enters, and the rest is what it says.
Plant string
CallTool string
CallArgs string
CallID string
PlantBody string
}
// fixtureInstance is one task with values. Scoring binds to this, never to the
// corpus: the corpus has no answers to bind to.
type fixtureInstance struct {
Task contextTask
Vars fixtureVars
Prompt string
ProbeQuery string
AnswerMarkers []string
CueMarker string
body string
callArgs string
}
// instantiateTask resolves one task against a generator.
func instantiateTask(t contextTask, rng *rand.Rand) (fixtureInstance, error) {
vars := newVars(t.Vars, rng)
inst := fixtureInstance{Task: t, Vars: vars}
var err error
for _, pair := range []struct {
dst *string
text string
}{
{&inst.Prompt, t.Prompt},
{&inst.ProbeQuery, t.ProbeQuery},
{&inst.body, t.PlantBody},
{&inst.callArgs, t.CallArgs},
} {
if *pair.dst, err = instantiate(pair.text, vars); err != nil {
return fixtureInstance{}, fmt.Errorf("%s: %w", t.ID, err)
}
}
for _, name := range t.AnswerVars {
value, ok := vars[name]
if !ok {
return fixtureInstance{}, fmt.Errorf("%s: answer var %q is not declared", t.ID, name)
}
inst.AnswerMarkers = append(inst.AnswerMarkers, value)
}
if t.CueVar != "" {
inst.CueMarker = vars[t.CueVar]
}
return inst, nil
}
// plant appends the instantiated target and returns its canonical position.
func (f fixtureInstance) plant(s *sessionstore.Session) int {
at := len(s.Messages)
if f.Task.Plant == plantAssistant {
s.Add(provider.Message{Role: provider.RoleAssistant, Content: f.body})
return at
}
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: f.Task.CallID, Name: f.Task.CallTool, Arguments: f.callArgs},
}})
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: f.Task.CallID, Name: f.Task.CallTool, Content: f.body})
return at
}
func code(name, word string) varSpec { return varSpec{Name: name, Kind: varCodename, Word: word} }
func num(name string, lo, hi int) varSpec {
return varSpec{Name: name, Kind: varInt, Min: lo, Max: hi}
}
// efficiencyTasks is the substrate a study measures on. Every task in it
// qualifies under the corpus bar; the stopping corpus is deliberately excluded,
// because a task carrying post-sufficient stopping behaviour swamps the effect
// an efficiency batch exists to measure.
func efficiencyTasks() []contextTask {
var out []contextTask
for _, t := range indexTasks() {
if t.Role == roleEfficiency {
out = append(out, t)
}
}
return out
}
func searchTasks() []contextTask {
return []contextTask{
{
ID: "r01-retry-boundary", Experiment: experimentSearch, TargetKind: "assistant_reasoning",
Prompt: "What ownership token did we settle on for the retry boundary, and under what tag did we file the reason for not moving retries above framing?",
ProbeQuery: "retry boundary ownership token framing",
Vars: []varSpec{code("token", "cobalt"), code("reason", "stream")},
AnswerVars: []string{"token", "reason"},
Plant: plantAssistant,
PlantBody: "The retry boundary remains at ingress. The ownership token is {{token}}. " +
"Moving retries above framing can duplicate a partially received stream, which we filed as {{reason}}.",
},
{
ID: "r02-checkpoint-anchor", Experiment: experimentSearch, TargetKind: "assistant_reasoning",
Prompt: "What was the codename of the checkpoint anchor policy, and what tag did we give the reason for not anchoring against digest positions?",
ProbeQuery: "checkpoint anchor policy digest positions",
Vars: []varSpec{code("policy", "northglass"), code("reason", "genlocal")},
AnswerVars: []string{"policy", "reason"},
Plant: plantAssistant,
PlantBody: "The checkpoint anchor policy is {{policy}}. Digest positions are generation-local — " +
"filed as {{reason}} — so recovery must anchor against canonical state.",
},
{
ID: "r03-recovery-cjk", Experiment: experimentSearch, TargetKind: "assistant_reasoning_cjk",
Prompt: "我们之前定的验证失败恢复策略代号叫什么?当时把“失败 digest 不能变成 baseline”这条登记在哪个标记下?",
ProbeQuery: "恢复策略 baseline digest 验证失败",
Vars: []varSpec{code("policy", "songzhen"), code("rule", "trustbase")},
AnswerVars: []string{"policy", "rule"},
Plant: plantAssistant,
PlantBody: "恢复策略代号是 {{policy}}。验证失败以后必须保留旧基线,不能把新的 digest 晋升为 baseline," +
"因为失败证据不能成为后续比较的可信基准;这条规则登记为 {{rule}}。",
},
{
ID: "r04-lease-fence", Experiment: experimentSearch, TargetKind: "assistant_reasoning",
Prompt: "那次 lease fence 检查报告的诊断标记是什么?expected 和 observed epoch 分别是多少?",
ProbeQuery: "lease fence writer epoch mismatch",
Vars: []varSpec{
code("marker", "fence"), num("expected", 400, 900), num("observed", 100, 399),
},
AnswerVars: []string{"marker", "expected", "observed"},
Plant: plantAssistant,
PlantBody: "The lease fence check reported {{marker}}: expected writer epoch {{expected}}, " +
"observed {{observed}}. We left the fence in place.",
},
{
ID: "r05-schema-guard", Experiment: experimentSearch, TargetKind: "assistant_reasoning",
Prompt: "当时 schema guard 报告的标记是什么,超预算多少 token?",
ProbeQuery: "schema guard provider visible surface budget",
Vars: []varSpec{code("marker", "amber"), num("over", 120, 480)},
AnswerVars: []string{"marker", "over"},
Plant: plantAssistant,
PlantBody: "The schema guard reported {{marker}}: the provider-visible tool schema exceeded " +
"the allowed surface by {{over}} tokens. We trimmed a description instead.",
},
{
ID: "r06-shadow-probe", Experiment: experimentSearch, TargetKind: "assistant_reasoning",
Prompt: "我们那次 shadow probe 的 marker、p95 divergence 和 generation 分别是多少?",
ProbeQuery: "shadow probe divergence generation marker",
Vars: []varSpec{
code("marker", "violet"),
{Name: "divergence", Kind: varDecimal, Min: 110, Max: 890},
num("generation", 14, 60),
},
AnswerVars: []string{"marker", "divergence", "generation"},
Plant: plantAssistant,
PlantBody: "The shadow probe came back as marker={{marker}} with a p95 divergence of " +
"{{divergence}} at generation={{generation}}, which we judged within tolerance.",
},
}
}
// efficiencyIndexTasks is what a study measures on: two tasks per cue tier.
func efficiencyIndexTasks() []contextTask {
out := indexQuarterTasks()
out = append(out, indexHalfTasks()...)
out = append(out, indexDefaultTasks()...)
return out
}
// indexQuarterTasks holds the quarter-tier literals.
func indexQuarterTasks() []contextTask {
return []contextTask{
{
ID: "i37-dispatchlag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/dispatchlag,它打印出的 dispatch lag epoch 是多少?",
ProbeQuery: "dispatchlag dispatch lag epoch",
Vars: []varSpec{code("cue", "dispatchlag"), code("epoch", "cobalt")},
AnswerVars: []string{"epoch"}, CueVar: "cue", CueTier: tierQuarter,
PlantAfterGen: 4,
Plant: plantCall, CallID: "p37", CallTool: "bash",
CallArgs: `{"command":"./scripts/dispatchlag {{cue}}"}`,
PlantBody: "dispatch lag epoch = {{epoch}}",
},
{
ID: "i118-fencelag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里运行过 ./scripts/fencelag,它打印出的 fence lag 是多少?",
ProbeQuery: "fencelag fence lag",
Vars: []varSpec{code("cue", "fencelag"), code("v", "slate")},
AnswerVars: []string{"v"}, CueVar: "cue", CueTier: tierQuarter,
PlantAfterGen: 4,
Plant: plantCall, CallID: "x118", CallTool: "bash",
CallArgs: `{"command":"./scripts/fencelag {{cue}}"}`,
PlantBody: "fence lag = {{v}}",
},
}
}
// indexHalfTasks holds the half-tier literals.
func indexHalfTasks() []contextTask {
return []contextTask{
{
ID: "i70-ingestfloor", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/ingestfloor,它打印出的 route quota 是多少?",
ProbeQuery: "ingestfloor quota",
Vars: []varSpec{code("cue", "ingestfloor"), num("quota", 1200, 9800)},
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
PlantAfterGen: 2,
Plant: plantCall, CallID: "q70", CallTool: "bash",
CallArgs: `{"command":"./scripts/ingestfloor {{cue}}"}`,
PlantBody: "route quota = {{quota}}",
},
{
ID: "i106-budgetseal", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/budgetseal,它打印出的 route quota 是多少?",
ProbeQuery: "budgetseal route quota",
Vars: []varSpec{code("cue", "budgetseal"), code("quota", "amber")},
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
PlantAfterGen: 2,
Plant: plantCall, CallID: "h106", CallTool: "bash",
CallArgs: `{"command":"./scripts/budgetseal {{cue}}"}`,
PlantBody: "route quota = {{quota}}",
},
}
}
// indexDefaultTasks holds the default-tier literals.
func indexDefaultTasks() []contextTask {
return []contextTask{
{
ID: "i47-tenantlease", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/tenantlease,它打印出的 tenant lease(秒)是多少?",
ProbeQuery: "tenantlease tenant lease",
Vars: []varSpec{code("cue", "tenantlease"), {Name: "lease", Kind: varDuration, Min: 15, Max: 300}},
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
PlantAfterGen: 0,
Plant: plantCall, CallID: "p47", CallTool: "bash",
CallArgs: `{"command":"./scripts/tenantlease {{cue}}"}`,
PlantBody: "tenant lease = {{lease}}",
},
{
ID: "i112-lagquota", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/lagquota,它打印出的 lagquota lease 是多少?",
ProbeQuery: "lagquota lagquota lease",
Vars: []varSpec{code("cue", "lagquota"), {Name: "lease", Kind: varDuration, Min: 20, Max: 420}},
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
PlantAfterGen: 0,
Plant: plantCall, CallID: "d112", CallTool: "bash",
CallArgs: `{"command":"./scripts/lagquota {{cue}}"}`,
PlantBody: "lagquota lease = {{lease}}",
},
}
}
// stoppingIndexTasks is the frozen study's six tasks, kept out of efficiency
// batches and measurable on their own with -task <id>.
func stoppingIndexTasks() []contextTask {
return []contextTask{
{
ID: "i01-transport-fallback", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "transport fallback probe 当时报告的 selector 是什么?",
ProbeQuery: "transport fallback probe selector",
Vars: []varSpec{code("cue", "tfprobe"), code("selector", "fern")},
AnswerVars: []string{"selector"}, CueVar: "cue", CueTier: tierQuarter,
PlantAfterGen: 4,
Plant: plantCall, CallID: "tf1", CallTool: "bash",
CallArgs: `{"command":"./scripts/probe {{cue}}"}`,
PlantBody: "fallback selector = {{selector}}",
},
{
ID: "i02-scheduler-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "scheduler handoff 当时记录的 grace 和 marker 是什么?",
ProbeQuery: "scheduler handoff grace marker",
Vars: []varSpec{
code("cue", "schedhandoff"), code("marker", "opal"),
{Name: "grace", Kind: varDuration, Min: 18, Max: 90},
},
AnswerVars: []string{"grace", "marker"}, CueVar: "cue", CueTier: tierQuarter,
PlantAfterGen: 4,
Plant: plantCall, CallID: "sh1", CallTool: "read_file",
CallArgs: `{"path":"scratch/{{cue}}.txt"}`,
PlantBody: "handoff grace = {{grace}}\nmarker = {{marker}}",
},
{
ID: "i03-coalescing", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "job completion 的 coalescing probe 最后记录的 window 和 marker 是多少?",
ProbeQuery: "coalescing policy completion window",
Vars: []varSpec{
code("cue", "coalescing"), code("marker", "cedar"),
{Name: "window", Kind: varDuration, Min: 40, Max: 140},
},
AnswerVars: []string{"window", "marker"}, CueVar: "cue", CueTier: tierHalf,
PlantAfterGen: 2,
Plant: plantCall, CallID: "cp1", CallTool: "bash",
CallArgs: `{"command":"grep {{cue}} scratch/job-notes.log"}`,
PlantBody: "completion coalesce window = {{window}}\nmarker = {{marker}}",
},
{
ID: "i04-recovery-fence", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "recovery fence 那次检查报告的 marker 和两个 epoch 是什么?",
ProbeQuery: "recovery fence epoch check",
Vars: []varSpec{
code("cue", "recfence"), code("marker", "quartz"),
num("expected", 400, 900), num("observed", 100, 399),
},
AnswerVars: []string{"marker", "expected", "observed"}, CueVar: "cue", CueTier: tierHalf,
PlantAfterGen: 2,
Plant: plantCall, CallID: "rf1", CallTool: "bash",
CallArgs: `{"command":"./scripts/check {{cue}}"}`,
PlantBody: "{{marker}}\nexpected epoch {{expected}}\nobserved epoch {{observed}}",
},
{
ID: "i05-cache-probe", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "cache probe 当时记录的 prefix salt 和 scope 是什么?",
ProbeQuery: "cache probe prefix salt scope",
Vars: []varSpec{code("cue", "cacheprobe"), code("salt", "comet"), code("scope", "lineage")},
AnswerVars: []string{"salt", "scope"}, CueVar: "cue", CueTier: tierDefault,
PlantAfterGen: 0,
Plant: plantCall, CallID: "cb1", CallTool: "read_file",
CallArgs: `{"path":"scratch/{{cue}}.txt"}`,
PlantBody: "prefix salt = {{salt}}\nscope = {{scope}}",
},
{
ID: "i06-dispatch-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "dispatch handoff probe 当时确认的 mode 和 marker 是什么?",
ProbeQuery: "dispatch handoff probe mode",
Vars: []varSpec{code("cue", "dispatchhandoff"), code("mode", "trigger"), code("marker", "iris")},
AnswerVars: []string{"mode", "marker"}, CueVar: "cue", CueTier: tierDefault,
PlantAfterGen: 0,
Plant: plantCall, CallID: "dh1", CallTool: "bash",
CallArgs: `{"command":"./scripts/probe {{cue}}"}`,
PlantBody: "handoff mode = {{mode}}\nmarker = {{marker}}",
},
}
}
// indexTasks is the whole index corpus, the efficiency substrate first.
func indexTasks() []contextTask {
out := efficiencyIndexTasks()
return append(out, stoppingIndexTasks()...)
}
func allTasks() []contextTask {
return append(searchTasks(), indexTasks()...)
}
func taskByID(id string) (contextTask, bool) {
for _, t := range allTasks() {
if t.ID == id {
return t, true
}
}
return contextTask{}, false
}
// tierScales is the fixture's contract: which arms must address a cue and
// which must not. A policy change that moves a cue across a boundary fails
// preflight rather than turning two arms into one experiment.
func tierScales(tier string) (visible, hidden []string) {
switch tier {
case tierQuarter:
return []string{"default", "half", "quarter"}, []string{"off"}
case tierHalf:
return []string{"default", "half"}, []string{"quarter", "off"}
case tierDefault:
return []string{"default"}, []string{"half", "quarter", "off"}
default:
return nil, nil
}
}
// boundaryPair is the one comparison an index task is built for: the narrowest
// scale that still addresses its cue, and the next one down. Holding the
// question fixed and moving only the affordance is what four arm averages
// cannot do, since each of those mixes six different questions.
func boundaryPair(tier string) (cue, noCue string) {
switch tier {
case tierQuarter:
return "quarter", "off"
case tierHalf:
return "half", "quarter"
case tierDefault:
return "default", "half"
default:
return "", ""
}
}
func validateCorpus() error {
seen := map[string]bool{}
for _, t := range allTasks() {
if seen[t.ID] {
return fmt.Errorf("duplicate task id %q", t.ID)
}
seen[t.ID] = true
if len(t.AnswerVars) == 0 || t.ProbeQuery == "" || t.Prompt == "" || t.PlantBody == "" {
return fmt.Errorf("%s: a task needs a prompt, a probe query, a body and answer vars", t.ID)
}
if t.Experiment == experimentIndex {
if t.CueVar == "" {
return fmt.Errorf("%s: an index task needs a cue var", t.ID)
}
if t.Role != roleEfficiency && t.Role != roleStopping {
return fmt.Errorf("%s: an index task needs a role (%s or %s)", t.ID, roleEfficiency, roleStopping)
}
if v, _ := tierScales(t.CueTier); v == nil {
return fmt.Errorf("%s: unknown cue tier %q", t.ID, t.CueTier)
}
}
if _, err := instantiateTask(t, seededRand(t.ID, "validate")); err != nil {
return err
}
}
return nil
}
-243
View File
@@ -1,243 +0,0 @@
// dose.go — does the cost step where the cue disappears?
package main
import (
"fmt"
"math"
"sort"
"strings"
)
// Not "a bigger index is better" — CueDirectRead says the addresses are rarely
// used. The hypothesis is that a visible action cue lowers retrieval cost,
// which predicts a step at each task's own boundary, not a slope.
// scaleOrder is the dose axis, widest first.
var scaleOrder = []string{"default", "half", "quarter", "off"}
// boundaryStep is the index into the three transitions where a tier's cue
// disappears: default->half is 0, half->quarter is 1, quarter->off is 2.
func boundaryStep(tier string) int {
switch tier {
case tierDefault:
return 0
case tierHalf:
return 1
case tierQuarter:
return 2
default:
return -1
}
}
// doseCurve is one task's readings across the four budgets.
type doseCurve struct {
Task string
Tier string
Metric string
Values [4]float64
// Missing marks arms that produced nothing. Printing those as zero draws a
// curve falling to the floor where in fact it was never measured.
Missing [4]bool
Present bool
// PreMean averages the arms where the cue is visible, PostMean the rest.
PreMean, PostMean float64
BoundaryDelta float64
// BoundaryAligned: the largest transition is the one where this task's cue
// disappears. A change elsewhere is variance shaped like an effect.
BoundaryAligned bool
}
func buildCurve(task, tier, metric string, byScale map[string]float64) doseCurve {
c := doseCurve{Task: task, Tier: tier, Metric: metric, Present: true}
for i, scale := range scaleOrder {
v, ok := byScale[scale]
if !ok {
c.Present, c.Missing[i] = false, true
}
c.Values[i] = v
}
step := boundaryStep(tier)
if !c.Present || step < 0 {
return c
}
visible := step + 1 // arms before the boundary still show the cue
c.PreMean = mean(c.Values[:visible])
c.PostMean = mean(c.Values[visible:])
c.BoundaryDelta = c.PostMean - c.PreMean
transitions := [3]float64{
math.Abs(c.Values[1] - c.Values[0]),
math.Abs(c.Values[2] - c.Values[1]),
math.Abs(c.Values[3] - c.Values[2]),
}
largest, at := 0.0, -1
for i, t := range transitions {
if t > largest {
largest, at = t, i
}
}
// A flat curve has no largest transition to align with anything.
c.BoundaryAligned = largest > 0 && at == step
return c
}
func mean(xs []float64) float64 {
if len(xs) == 0 {
return 0
}
sum := 0.0
for _, x := range xs {
sum += x
}
return sum / float64(len(xs))
}
func median(xs []float64) float64 {
if len(xs) == 0 {
return 0
}
s := append([]float64(nil), xs...)
sort.Float64s(s)
mid := len(s) / 2
if len(s)%2 == 1 {
return s[mid]
}
return (s[mid-1] + s[mid]) / 2
}
// doseMetrics are the readings a curve is drawn for.
var doseMetrics = []struct {
name string
of func(contextMetrics) float64
}{
{"searches", func(m contextMetrics) float64 { return float64(m.SearchCalls) }},
{"recall-tokens", func(m contextMetrics) float64 { return float64(m.RecallReturnedTokens) }},
{"tool-rounds", func(m contextMetrics) float64 { return float64(m.RetrievalRounds + m.EscapeCalls) }},
{"escapes", func(m contextMetrics) float64 { return float64(m.EscapeCalls) }},
}
// reportDoseResponse draws each task's curve and reports where the steps fell.
func reportDoseResponse(all []contextMetrics) string {
valid := map[string]map[string]contextMetrics{}
for _, m := range all {
if m.Failed || m.Contaminated || m.FailureStage == "RunError" {
continue
}
scale := strings.TrimPrefix(m.Arm, "index-")
if valid[m.Task] == nil {
valid[m.Task] = map[string]contextMetrics{}
}
valid[m.Task][scale] = m
}
var b strings.Builder
b.WriteString("\n## Dose-response, per task\n")
b.WriteString(" The arrow marks where this task's cue stops being addressed.\n")
aligned := map[string]int{}
counted := map[string]int{}
deltas := map[string][]float64{}
for _, t := range indexTasks() {
arms := valid[t.ID]
if len(arms) == 0 {
continue
}
step := boundaryStep(t.CueTier)
fmt.Fprintf(&b, "\n %s (cue survives to %s)\n", t.ID, t.CueTier)
fmt.Fprintf(&b, " %-14s%s\n", "", scaleHeader(step))
for _, metric := range doseMetrics {
byScale := map[string]float64{}
for scale, m := range arms {
byScale[scale] = metric.of(m)
}
c := buildCurve(t.ID, t.CueTier, metric.name, byScale)
mark := " "
if c.BoundaryAligned {
mark = "*"
aligned[metric.name]++
}
if c.Present {
counted[metric.name]++
deltas[metric.name] = append(deltas[metric.name], c.BoundaryDelta)
}
delta := fmt.Sprintf("Δ%+8.1f", c.BoundaryDelta)
if !c.Present {
delta = " no curve"
}
fmt.Fprintf(&b, " %-14s%s %s %s\n", metric.name, curveCells(c), delta, mark)
}
}
b.WriteString("\n## Where the step fell (* = largest transition is at the cue boundary)\n")
for _, metric := range doseMetrics {
d := deltas[metric.name]
sensitivity := ""
if len(d) > 1 {
sensitivity = fmt.Sprintf(" without the largest: %+.1f", meanWithoutLargest(d))
}
fmt.Fprintf(&b, " %-14s boundary-aligned %d/%d mean Δ%+.1f median Δ%+.1f%s\n",
metric.name, aligned[metric.name], counted[metric.name], mean(d), median(d), sensitivity)
}
return b.String()
}
// meanWithoutLargest is the robustness check: the largest effect is reported
// and then set aside, so a conclusion never rests on one task alone.
func meanWithoutLargest(d []float64) float64 {
if len(d) < 2 {
return mean(d)
}
worst, at := 0.0, 0
for i, v := range d {
if math.Abs(v) > worst {
worst, at = math.Abs(v), i
}
}
rest := append(append([]float64(nil), d[:at]...), d[at+1:]...)
return mean(rest)
}
func scaleHeader(step int) string {
var b strings.Builder
for i, s := range scaleOrder {
if i == step+1 {
s = "↓" + s
}
fmt.Fprintf(&b, "%8s", s)
}
return b.String()
}
// curveCells prints a reading per arm, and a dash where an arm produced none.
func curveCells(c doseCurve) string {
var b strings.Builder
for i, v := range c.Values {
if c.Missing[i] {
fmt.Fprintf(&b, "%8s", "-")
continue
}
fmt.Fprintf(&b, "%8.0f", v)
}
return b.String()
}
func percentile(xs []float64, q float64) float64 {
if len(xs) == 0 {
return 0
}
s := append([]float64(nil), xs...)
sort.Float64s(s)
i := int(float64(len(s)-1) * q)
return s[i]
}
func maxOf(xs []float64) float64 {
out := 0.0
for _, x := range xs {
if x > out {
out = x
}
}
return out
}
@@ -1,306 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
)
// blockTask makes one task fail before its first model turn: its own directory
// is a regular file, so buildFixture cannot create the session under it. It is
// the same early exit as a failed fixture build, session load, or
// answer-on-disk guard, and it needs no provider.
func blockTask(t *testing.T, root, id string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, id), []byte("not a directory\n"), 0o644); err != nil {
t.Fatal(err)
}
}
// mixedTaskPair is one task whose runs all fail and one whose runs all succeed.
// Both are drawn from the corpus, so the batch is the real pipeline.
func mixedTaskPair() (blocked, ok contextTask) {
blocked, ok = indexTasks()[0], indexTasks()[1]
for _, t := range indexTasks() {
if t.Role == roleEfficiency && len(armsFor(t)) == 2 {
ok = t
break
}
}
return blocked, ok
}
// runBlockedExperiment runs one dry index batch over the given tasks, with the
// named task failing before any model turn, and returns the batch directory, the
// runs, the manifest, and everything the run printed.
func runBlockedExperiment(t *testing.T, tasks []contextTask, blocked ...string) (string, []contextMetrics, batchManifest, string) {
t.Helper()
root := t.TempDir()
for _, id := range blocked {
blockTask(t, root, id)
}
out := captureStdout(t, func() {
if code := runExperiment("run-index", experimentIndex, root, true, tasks); code != 0 {
t.Errorf("runExperiment exit = %d, want 0 (the batch finished)", code)
}
})
runs, _ := readResultLines(t, root)
manifest, _ := readManifest(t, root)
return root, runs, manifest, out
}
// captureStdout reads everything the function prints. os.Pipe is unbuffered, so
// the reader must run beside the call.
func captureStdout(t *testing.T, f func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
old := os.Stdout
os.Stdout = w
done := make(chan string, 1)
go func() {
var b strings.Builder
buf := make([]byte, 4096)
for {
n, err := r.Read(buf)
b.Write(buf[:n])
if err != nil {
break
}
}
done <- b.String()
}()
f()
os.Stdout = old
w.Close()
text := <-done
r.Close()
return text
}
// funnelScored reads the "runs=N" column of the named arm's funnel line out of
// the printed tables, so the assertion is on the number a reader sees.
func funnelScored(t *testing.T, out, arm string) int {
t.Helper()
for line := range strings.SplitSeq(out, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 || fields[0] != arm || !strings.HasPrefix(fields[1], "runs=") {
continue
}
n, err := strconv.Atoi(strings.TrimPrefix(fields[1], "runs="))
if err != nil {
t.Fatalf("funnel line for %s: %v\n%s", arm, err, line)
}
return n
}
t.Fatalf("no funnel line for arm %s:\n%s", arm, out)
return 0
}
// The batch report states both counts on one line, so a reader of the scrollback
// sees the scored set and the failures without counting funnel rows.
func reportCounts(t *testing.T, out string) (scored, failed int) {
t.Helper()
for line := range strings.SplitSeq(out, "\n") {
if !strings.Contains(line, " scored=") || !strings.Contains(line, " failed=") {
continue
}
for f := range strings.FieldsSeq(line) {
switch {
case strings.HasPrefix(f, "scored="):
scored, _ = strconv.Atoi(strings.TrimPrefix(f, "scored="))
case strings.HasPrefix(f, "failed="):
failed, _ = strconv.Atoi(strings.TrimPrefix(f, "failed="))
}
}
return scored, failed
}
t.Fatalf("no line reports scored= and failed=:\n%s", out)
return 0, 0
}
// A batch that mixes a task that never ran with one that did must read as its
// successful runs only: the failed task leaves Scored, the funnel, and the
// completed count, and is reported as a failure with its identity instead.
// Every assertion here is a number, not the absence of an empty row.
func TestMixedBatchCountsOnlyTheRunThatProducedEvidence(t *testing.T) {
blocked, ok := mixedTaskPair()
_, runs, manifest, out := runBlockedExperiment(t, []contextTask{blocked, ok}, blocked.ID)
wantScored, wantFailed := len(armsFor(ok)), len(armsFor(blocked))
if manifest.CompletedRuns != wantScored || manifest.FailedRuns != wantFailed {
t.Fatalf("manifest completed=%d failed=%d, want %d and %d (planned=%d)",
manifest.CompletedRuns, manifest.FailedRuns, wantScored, wantFailed, manifest.PlannedRuns)
}
if len(runs) != manifest.CompletedRuns+manifest.FailedRuns {
t.Fatalf("results.jsonl has %d rows but completed=%d failed=%d",
len(runs), manifest.CompletedRuns, manifest.FailedRuns)
}
for _, id := range manifest.TaskIDs {
if id == blocked.ID {
t.Fatalf("task_ids lists %s, which produced no evidence: %v", blocked.ID, manifest.TaskIDs)
}
}
scored, failures := 0, 0
for _, m := range runs {
if m.Failed {
failures++
if m.Task != blocked.ID || m.FailureStage == "" || m.Reason == "" {
t.Fatalf("failed row lost its identity or stage: %+v", m)
}
continue
}
scored++
if m.Task != ok.ID {
t.Fatalf("a run scored from the wrong task: %+v", m)
}
}
if scored != wantScored || failures != wantFailed {
t.Fatalf("rows scored=%d failed=%d, want %d and %d", scored, failures, wantScored, wantFailed)
}
// Each arm's funnel holds exactly the successful task's runs, and the two
// scored counts agree with each other and with the manifest.
total := 0
for _, arm := range armsFor(ok) {
total += funnelScored(t, out, arm.name)
}
if total != manifest.CompletedRuns {
t.Fatalf("funnels scored %d runs, manifest completed_runs = %d:\n%s", total, manifest.CompletedRuns, out)
}
if gotScored, gotFailed := reportCounts(t, out); gotScored != manifest.CompletedRuns || gotFailed != manifest.FailedRuns {
t.Fatalf("report says scored=%d failed=%d, manifest says %d and %d",
gotScored, gotFailed, manifest.CompletedRuns, manifest.FailedRuns)
}
if strings.Contains(out, "## Where it broke") && strings.Contains(out, "=-\n") {
t.Fatalf("the stage table kept an empty stage:\n%s", out)
}
}
// A batch where every run fails must not read as an empty success: zero scored
// runs, a nonzero failed count, and both stated.
func TestAllFailedBatchReportsZeroScoredAndNonzeroFailed(t *testing.T) {
blocked, _ := mixedTaskPair()
_, runs, manifest, out := runBlockedExperiment(t, []contextTask{blocked}, blocked.ID)
if manifest.CompletedRuns != 0 {
t.Fatalf("completed_runs = %d, want 0 (nothing produced evidence)", manifest.CompletedRuns)
}
if manifest.FailedRuns != manifest.PlannedRuns || manifest.FailedRuns == 0 {
t.Fatalf("failed_runs = %d, planned_runs = %d: every planned run failed",
manifest.FailedRuns, manifest.PlannedRuns)
}
if len(manifest.TaskIDs) != 0 {
t.Fatalf("task_ids = %v, want none", manifest.TaskIDs)
}
for i, m := range runs {
if !m.Failed || m.FailureStage == "" || m.Task != blocked.ID {
t.Fatalf("results.jsonl line %d is not a marked failure: %+v", i+1, m)
}
}
total := 0
for _, arm := range armsFor(blocked) {
total += funnelScored(t, out, arm.name)
}
if total != 0 {
t.Fatalf("funnels scored %d runs although nothing ran:\n%s", total, out)
}
if gotScored, gotFailed := reportCounts(t, out); gotScored != 0 || gotFailed != manifest.FailedRuns {
t.Fatalf("report says scored=%d failed=%d, want 0 and %d", gotScored, gotFailed, manifest.FailedRuns)
}
}
// The control: with nothing blocked, a batch is what it always was. Every run
// is completed with a stage, two identical batches write byte-identical results
// and the same manifest header, and the new failure field stays out of it.
func TestAllSuccessfulBatchKeepsItsArtifacts(t *testing.T) {
tasks := indexTasks()
first, runs, manifest, out := runBlockedExperiment(t, tasks)
second, again, repeat, _ := runBlockedExperiment(t, tasks)
if manifest.CompletedRuns != len(runs) || manifest.FailedRuns != 0 {
t.Fatalf("completed=%d failed=%d rows=%d, want every row completed",
manifest.CompletedRuns, manifest.FailedRuns, len(runs))
}
if manifest.CompletedRuns != manifest.PlannedRuns {
t.Fatalf("completed=%d planned=%d, want all planned", manifest.CompletedRuns, manifest.PlannedRuns)
}
for i, m := range runs {
if m.Failed || m.Task == "" || m.Arm == "" || m.FailureStage == "" {
t.Fatalf("results.jsonl line %d is not a completed run: %+v", i+1, m)
}
}
if gotScored, gotFailed := reportCounts(t, out); gotScored != manifest.CompletedRuns || gotFailed != 0 {
t.Fatalf("report says scored=%d failed=%d, want %d and 0", gotScored, gotFailed, manifest.CompletedRuns)
}
if !reflect.DeepEqual(runs, again) {
t.Fatal("two identical successful batches scored differently")
}
// Byte-for-byte on the runs. Trajectories carry per-message wall-clock
// stamps, so they are compared field by field instead.
if got, want := readFile(t, second, "results.jsonl"), readFile(t, first, "results.jsonl"); got != want {
t.Fatalf("an unchanged batch wrote different results.jsonl:\n%s\n---\n%s", want, got)
}
gotTraj, wantTraj := withoutStamps(t, readFile(t, second, "trajectories.jsonl")),
withoutStamps(t, readFile(t, first, "trajectories.jsonl"))
if gotTraj != wantTraj {
t.Fatalf("an unchanged batch wrote different trajectories:\n%s\n---\n%s", wantTraj, gotTraj)
}
repeat.StartedAt, repeat.FinishedAt = manifest.StartedAt, manifest.FinishedAt
repeat.TrajectoriesSHA256 = manifest.TrajectoriesSHA256
if !reflect.DeepEqual(repeat, manifest) {
t.Fatalf("an unchanged batch wrote a different manifest:\n%+v\n---\n%+v", manifest, repeat)
}
if repeat.FailedRuns != 0 {
t.Fatalf("a batch with no failure reports failed_runs=%d", repeat.FailedRuns)
}
}
// withoutStamps drops the createdAt stamp every message carries, which is the
// one field a dry batch cannot reproduce between two runs.
func withoutStamps(t *testing.T, text string) string {
t.Helper()
var b strings.Builder
for line := range strings.SplitSeq(strings.TrimSpace(text), "\n") {
var row map[string]any
if err := json.Unmarshal([]byte(line), &row); err != nil {
t.Fatal(err)
}
stripStamp(row)
out, err := json.Marshal(row)
if err != nil {
t.Fatal(err)
}
b.Write(out)
b.WriteByte('\n')
}
return b.String()
}
func stripStamp(v any) {
switch typed := v.(type) {
case map[string]any:
delete(typed, "createdAt")
delete(typed, "workDurationMs")
for _, child := range typed {
stripStamp(child)
}
case []any:
for _, child := range typed {
stripStamp(child)
}
}
}
func readFile(t *testing.T, root, name string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(root, name))
if err != nil {
t.Fatal(err)
}
return string(data)
}
-205
View File
@@ -1,205 +0,0 @@
package main
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"reasonix/internal/state/sessionstore"
"strings"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/runtime/agent"
)
// A fixture is a real session that has really been compacted: no bench-only
// state shape, so the loader and the projection restore have to work on it.
const (
fixtureWindow = 60000
fixtureGenerations = 5
// fillerBytes per unit is what makes a fold necessary rather than optional.
fillerBytes = 6000
)
// digestProvider answers a summarizer without naming anything the corpus
// plants. A digest that happened to quote the answer would leave it visible
// and silently turn the task into a no-op.
type digestProvider struct{ calls int }
func (p *digestProvider) Name() string { return "fixture-digest" }
func (p *digestProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
p.calls++
ch := make(chan provider.Chunk, 2)
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Earlier work\nEarlier work continued across several files; nothing outstanding was recorded."}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
// fillerCall is one ordinary read whose only job is to take up room — in the
// window, and in the fold index ahead of a cue.
func fillerCall(sess *sessionstore.Session, n int) {
id := fmt.Sprintf("f%04d", n)
sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: id, Name: "read_file", Arguments: fmt.Sprintf(`{"path":"internal/mod%03d/handler%03d.go"}`, n%37, n)},
}})
sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: fillerBody(n)})
}
func fillerBody(n int) string {
var b strings.Builder
fmt.Fprintf(&b, "package mod%03d\n\n", n%37)
for b.Len() < fillerBytes {
fmt.Fprintf(&b, "func handler%03dStep%02d() error { return nil }\n", n, b.Len()%64)
}
return b.String()
}
// builtFixture is one task's history after real folds, plus where its target
// ended up.
type builtFixture struct {
Session *sessionstore.Session
State sessionstore.CompactionState
Target int
Path string
Instance fixtureInstance
}
// buildFixture plays a task's history through the real agent and folds it at
// the given arm, writing the session and its context state where the agent
// itself would.
func buildFixture(f fixtureInstance, arm ablation.Set, sessionPath string) (builtFixture, error) {
t := f.Task
sess := sessionstore.NewSession("You are a coding agent.")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "Work through the transport and scheduler backlog."})
n := 0
at := -1
a := agent.New(&digestProvider{}, tool.NewRegistry(), sess, agent.Options{
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
SessionPath: sessionPath, KeepPolicy: agent.KeepErrors, Ablation: arm,
}, event.Discard)
for gen := range fixtureGenerations {
if gen == t.PlantAfterGen {
at = f.plant(sess)
}
for range 8 {
fillerCall(sess, n)
n++
}
if _, err := a.CompactNow(context.Background(), agent.CompactRequest{}); err != nil {
return builtFixture{}, fmt.Errorf("%s: generation %d fold: %w", t.ID, gen, err)
}
}
if at < 0 {
return builtFixture{}, fmt.Errorf("%s: PlantAfterGen %d is past the last generation", t.ID, t.PlantAfterGen)
}
// projectionCoversTail cannot revalidate a projection covering the whole
// transcript: it needs a live tail, which a real session always has because
// work continues after a fold. Neutral text — the tail is provider-visible.
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "Backlog swept; ready for the next question."})
if err := sess.Save(sessionPath); err != nil {
return builtFixture{}, fmt.Errorf("%s: save session: %w", t.ID, err)
}
state, ok, err := sessionstore.LoadCompactionState(sessionPath)
if err != nil {
return builtFixture{}, fmt.Errorf("%s: load context state: %w", t.ID, err)
}
if !ok || len(state.Projection.Messages) == 0 {
return builtFixture{}, fmt.Errorf("%s: no projection was installed", t.ID)
}
if state.Projection.CoveredCount <= at {
return builtFixture{}, fmt.Errorf("%s: target #%d is not inside the folded region (covered %d)",
t.ID, at, state.Projection.CoveredCount)
}
return builtFixture{Session: sess, State: state, Target: at, Path: sessionPath, Instance: f}, nil
}
// visibleContext is the model-visible view: the stored projection spliced with
// everything canonical that came after it.
func visibleContext(f builtFixture) []provider.Message {
canonical := f.Session.Snapshot()
out := append([]provider.Message(nil), f.State.Projection.Messages...)
if n := f.State.Projection.CoveredCount; n >= 0 && n < len(canonical) {
out = append(out, canonical[n:]...)
}
return out
}
// visibleText is everything the model would read, as one string.
func visibleText(f builtFixture) string {
var b strings.Builder
for _, m := range visibleContext(f) {
b.WriteString(m.Content)
b.WriteString("\n")
for _, tc := range m.ToolCalls {
b.WriteString(tc.Name)
b.WriteString(" ")
b.WriteString(string(tc.Arguments))
b.WriteString("\n")
}
}
return b.String()
}
// sealFixture removes the fixture from disk once the agent holds it in memory.
// The canonical transcript is the answer, and the sandbox mounts the host
// read-only by design — so a model that finds the path reads the answer, which
// one did. The agent has already loaded the session and its sidecar by the time
// this runs; a failed autosave afterwards costs the benchmark nothing.
func sealFixture(sessionPath string) {
// Every file, not two named ones: a session writes an event log and a
// context sidecar beside the transcript, and session.events.jsonl still
// carried the answer after the other two were sealed by name.
dir := filepath.Dir(sessionPath)
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if !e.IsDir() {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
// Deny the directory too, so an autosave cannot recreate what was removed.
_ = os.Chmod(dir, 0o500)
}
// unsealFixture restores the directory so the harness can clean up after
// itself.
func unsealFixture(sessionPath string) {
_ = os.Chmod(filepath.Dir(sessionPath), 0o700)
}
// answerOnDisk reports any readable file under dir holding a current-run
// answer. Two named files were sealed because two leaks were found in them;
// this scans the directory, so a path that moves — an autosave, a recovery
// tombstone, a future sidecar — fails here instead of on a paid run.
func answerOnDisk(dir string, markers []string) (string, string) {
var atPath, found string
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || atPath != "" {
return nil //nolint:nilerr // an unreadable entry is not a leak
}
body, readErr := os.ReadFile(path)
if readErr != nil {
return nil
}
for _, marker := range markers {
if len(marker) >= 6 && strings.Contains(string(body), marker) {
atPath, found = path, marker
return filepath.SkipAll
}
}
return nil
})
return atPath, found
}
-139
View File
@@ -1,139 +0,0 @@
// instance.go — a task template becomes one run's concrete history.
package main
import (
"crypto/rand"
"fmt"
"hash/fnv"
"io"
mathrand "math/rand"
"strings"
)
// The invariant this file exists for: no literal sufficient to satisfy
// AnswerRecovered for a concrete run may exist in this repository before that
// run begins. Grepping the corpus reveals the question, never the answer.
// varKind is how one placeholder's value is generated. Values stay shaped like
// real engineering records — a UUID would defeat the leak but turn every task
// into "find the random string".
type varKind string
const (
varCodename varKind = "codename" // cobalt-k7m4q9
varInt varKind = "int" // 731
varDecimal varKind = "decimal" // 0.0437
varDuration varKind = "duration" // 43ms
)
// varSpec declares one placeholder. Min/Max bound the numeric kinds.
type varSpec struct {
Name string
Kind varKind
Word string // codename stem, e.g. "cobalt"
Min int
Max int
}
// codenameEntropy is how much of a codename is unguessable. Five base32 digits
// is about 25 bits: far past what a model can reach by knowing the stem.
const codenameEntropy = 5
const codenameAlphabet = "abcdefghijkmnpqrstuvwxyz23456789"
// fixtureVars is one run's instantiated values.
type fixtureVars map[string]string
// newVars generates values for every spec. A nil rng means deterministic.
func newVars(specs []varSpec, rng *mathrand.Rand) fixtureVars {
out := fixtureVars{}
for _, spec := range specs {
switch spec.Kind {
case varCodename:
out[spec.Name] = spec.Word + "-" + randomToken(rng, codenameEntropy)
case varInt:
out[spec.Name] = fmt.Sprint(randomBetween(rng, spec.Min, spec.Max))
case varDecimal:
out[spec.Name] = fmt.Sprintf("0.%04d", randomBetween(rng, spec.Min, spec.Max))
case varDuration:
out[spec.Name] = fmt.Sprintf("%dms", randomBetween(rng, spec.Min, spec.Max))
}
}
return out
}
func randomToken(rng *mathrand.Rand, n int) string {
var b strings.Builder
for range n {
b.WriteByte(codenameAlphabet[randomBetween(rng, 0, len(codenameAlphabet)-1)])
}
return b.String()
}
func randomBetween(rng *mathrand.Rand, lo, hi int) int {
if hi <= lo {
return lo
}
return lo + rng.Intn(hi-lo+1)
}
// seededRand is the deterministic generator preflight and tests use, so a
// fixture's tier calibration and its golden state reproduce exactly.
func seededRand(parts ...string) *mathrand.Rand {
h := fnv.New64a()
for _, p := range parts {
h.Write([]byte(p))
h.Write([]byte{0})
}
return mathrand.New(mathrand.NewSource(int64(h.Sum64())))
}
// liveRand is the generator a paid run uses: every run gets values that existed
// nowhere before it started.
func liveRand() *mathrand.Rand {
rng, err := liveRandFrom(rand.Reader)
if err != nil {
panic("contextbench: no entropy for a live run: " + err.Error())
}
return rng
}
// liveRandFrom seeds a generator from an entropy source, so what a run's values
// are unpredictable from is the source and nothing else.
func liveRandFrom(entropy io.Reader) (*mathrand.Rand, error) {
var seed [8]byte
if _, err := io.ReadFull(entropy, seed[:]); err != nil {
return nil, err
}
var n int64
for _, b := range seed {
n = n<<8 | int64(b)
}
return mathrand.New(mathrand.NewSource(n)), nil
}
// instantiate substitutes {{name}} and refuses anything left over. The grammar
// is one identifier in double braces: no conditionals, no loops, no defaults.
// This is fixture data, not a template language.
func instantiate(text string, vars fixtureVars) (string, error) {
out := text
for name, value := range vars {
out = strings.ReplaceAll(out, "{{"+name+"}}", value)
}
if strings.Contains(out, "{{") || strings.Contains(out, "}}") {
return "", fmt.Errorf("unresolved placeholder in %q", firstPlaceholder(out))
}
return out, nil
}
func firstPlaceholder(text string) string {
i := strings.Index(text, "{{")
if i < 0 {
return text
}
j := strings.Index(text[i:], "}}")
if j < 0 {
return text[i:min(i+40, len(text))]
}
return text[i : i+j+2]
}
-144
View File
@@ -1,144 +0,0 @@
// ContextRetrievalBench builds the fixtures the long-horizon retrieval
// experiments run against, and proves they are worth paying a provider for.
//
// -mode=calibrate find each index task's lead-call count for its cue tier
// -mode=preflight build every task under every arm and check the contract
//
// Nothing here calls a provider. The experiments themselves do; this is what
// says they would measure what they claim to.
package main
import (
"flag"
"fmt"
"os"
"sort"
"strings"
)
func main() {
mode := flag.String("mode", "preflight", "calibrate | preflight | snippet | adversarial | run-search | run-boundary | run-index")
work := flag.String("work", "", "directory for built fixtures (default: a temp dir)")
only := flag.String("task", "", "limit to one task id")
dry := flag.Bool("dry", false, "run modes: drive the pipeline with a scripted provider instead of paying for one")
flag.Parse()
if err := validateCorpus(); err != nil {
fmt.Fprintln(os.Stderr, "corpus:", err)
os.Exit(2)
}
root := *work
if root == "" {
dir, err := os.MkdirTemp("", "context-retrieval-*")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
defer os.RemoveAll(dir)
root = dir
}
tasks := allTasks()
if *only != "" {
t, ok := taskByID(*only)
if !ok {
fmt.Fprintf(os.Stderr, "unknown task %q\n", *only)
os.Exit(2)
}
tasks = []contextTask{t}
}
switch *mode {
case "calibrate":
os.Exit(runCalibrate(tasks, root))
case "preflight":
os.Exit(runPreflight(tasks, root))
case "run-search":
os.Exit(runExperiment(*mode, experimentSearch, root, *dry, tasks))
case "run-boundary":
os.Exit(runBoundaries(*mode, root))
case "snippet":
os.Exit(runSnippetAudit(root, 100))
case "adversarial":
os.Exit(runAdversarial(root))
case "run-index":
batch := tasks
if *only == "" {
// A batch measures the efficiency substrate only. Naming one task
// with -task runs that task, role included, so the stopping corpus
// stays measurable on its own.
batch = efficiencyTasks()
}
os.Exit(runExperiment(*mode, experimentIndex, root, *dry, batch))
default:
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
os.Exit(2)
}
}
func runPreflight(tasks []contextTask, root string) int {
var all []finding
for _, t := range tasks {
found, err := checkTask(t, root)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", t.ID, err)
return 2
}
all = append(all, found...)
status := "ok"
if len(found) > 0 {
status = fmt.Sprintf("%d problem(s)", len(found))
}
fmt.Printf("%-24s %-6s gen=%-2d %s\n", t.ID, t.Experiment, t.PlantAfterGen, status)
}
if len(all) == 0 {
fmt.Printf("\n%d fixtures ready.\n", len(tasks))
return 0
}
fmt.Fprintf(os.Stderr, "\n%d preflight problem(s):\n", len(all))
sort.Slice(all, func(i, j int) bool { return all[i].Task < all[j].Task })
for _, f := range all {
fmt.Fprintln(os.Stderr, " ", f)
}
return 1
}
// runCalibrate reports, for each index task, the lead-call counts at which its
// cue is still addressed by each budget. The corpus freezes one number from
// this; it is never computed at run time, or the implementation under test
// would be moving its own goalposts.
func runCalibrate(tasks []contextTask, root string) int {
for _, t := range tasks {
if t.Experiment != experimentIndex {
continue
}
var rows []string
for lead := range fixtureGenerations {
probe := t
probe.PlantAfterGen = lead
var visibleAt []string
for _, scale := range armScales {
inst, ierr := instantiateTask(probe, seededRand(t.ID, scale, "calibrate"))
if ierr != nil {
fmt.Fprintln(os.Stderr, ierr)
return 2
}
f, err := buildFixture(inst, armFor(scale, false),
fmt.Sprintf("%s/calib/%s/%d-%s/session.jsonl", root, t.ID, lead, scale))
if err != nil {
fmt.Fprintf(os.Stderr, "%s gen=%d %s: %v\n", t.ID, lead, scale, err)
return 2
}
if strings.Contains(visibleText(f), inst.CueMarker) {
visibleAt = append(visibleAt, scale)
}
}
rows = append(rows, fmt.Sprintf("planted after gen %d, cue visible at: %s", lead, strings.Join(visibleAt, ",")))
}
fmt.Printf("\n%s (wants tier %s)\n", t.ID, t.CueTier)
for _, r := range rows {
fmt.Println(" ", r)
}
}
return 0
}
-189
View File
@@ -1,189 +0,0 @@
package main
import (
"bytes"
"fmt"
"maps"
mathrand "math/rand"
"os/exec"
"slices"
"strings"
"testing"
)
// The same checks the preflight mode runs, under go test: a compaction change
// that invalidates the fixtures fails here rather than three hundred API calls
// later.
func TestCorpusIsWellFormed(t *testing.T) {
if err := validateCorpus(); err != nil {
t.Fatal(err)
}
if got := len(searchTasks()); got != 6 {
t.Errorf("search experiment has %d tasks, want 6", got)
}
if got := len(indexTasks()); got != 12 {
t.Errorf("index experiment has %d tasks, want 12", got)
}
stopping := map[string]bool{}
tiers := map[string]int{}
for _, task := range indexTasks() {
if task.Role == roleStopping {
stopping[task.ID] = true
continue
}
tiers[task.CueTier]++
}
if !stopping["i03-coalescing"] || !stopping["i04-recovery-fence"] {
t.Errorf("the stopping corpus is %v; it must keep i03-coalescing and i04-recovery-fence", stopping)
}
// Two per tier, over the efficiency substrate, is what makes the staircase
// readable: the same task can be compared across the boundary its cue sits
// on, instead of two different questions being compared across two arms.
for _, tier := range []string{tierQuarter, tierHalf, tierDefault} {
if tiers[tier] != 2 {
t.Errorf("tier %s has %d tasks, want 2", tier, tiers[tier])
}
}
}
// The role decides which batch a task enters; the literal lists are only where
// it is declared, so the two must not drift.
func TestIndexListsMatchTheirRoles(t *testing.T) {
for _, task := range append(append(indexQuarterTasks(), indexHalfTasks()...), indexDefaultTasks()...) {
if task.Role != roleEfficiency {
t.Errorf("%s sits in an efficiency list with role %q", task.ID, task.Role)
}
}
for _, task := range stoppingIndexTasks() {
if task.Role != roleStopping {
t.Errorf("%s sits in the stopping list with role %q", task.ID, task.Role)
}
}
}
// The invariant the whole instantiation layer exists for: no literal a scorer
// needs may be readable in this repository before a run starts.
func TestNoAnswerLiteralExistsInTheRepository(t *testing.T) {
root, err := repoRoot()
if err != nil {
t.Skip(err)
}
for _, task := range allTasks() {
inst, err := instantiateTask(task, seededRand(task.ID, "leak-check"))
if err != nil {
t.Fatal(err)
}
for _, marker := range append(inst.AnswerMarkers, inst.CueMarker) {
if len(marker) < 6 {
continue // a bare number is not a literal anyone can grep for
}
if found, where := grepRepo(t, root, marker); found {
t.Errorf("%s: %q is readable at %s before the run", task.ID, marker, where)
}
}
}
}
// A run's values are unpredictable from a previous run only if the live
// generator is seeded from entropy. Comparing answers across two instances
// cannot assert that: a task whose answer is a bounded number collides by
// chance (1/8601 to 1/286 per task), so the check is on the seed source.
func TestLiveRandDrawsFromEntropy(t *testing.T) {
a, b := liveRand(), liveRand()
for range 4 {
if a.Int63() != b.Int63() {
return
}
}
t.Fatal("two live generators produced the same stream: they are seeded alike")
}
// The source alone decides an instance: the same entropy gives the same
// answers, different entropy gives different values.
func TestLiveRandSourceDecidesTheInstance(t *testing.T) {
same := func() *mathrand.Rand {
rng, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{7}, 8)))
if err != nil {
t.Fatal(err)
}
return rng
}
other, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{9}, 8)))
if err != nil {
t.Fatal(err)
}
if _, err := liveRandFrom(bytes.NewReader(nil)); err == nil {
t.Fatal("an empty entropy source was accepted")
}
for _, task := range allTasks() {
a, _ := instantiateTask(task, same())
b, _ := instantiateTask(task, same())
if !slices.Equal(a.AnswerMarkers, b.AnswerMarkers) || !maps.Equal(a.Vars, b.Vars) {
t.Errorf("%s: the same entropy gave different instances", task.ID)
}
c, _ := instantiateTask(task, other)
if maps.Equal(a.Vars, c.Vars) {
t.Errorf("%s: different entropy gave the same values", task.ID)
}
}
}
// A preflight that could not reproduce would calibrate against one run's
// nonces and report about another's.
func TestSeededInstancesReproduce(t *testing.T) {
for _, task := range allTasks() {
a, _ := instantiateTask(task, seededRand(task.ID, "x"))
b, _ := instantiateTask(task, seededRand(task.ID, "x"))
if !slices.Equal(a.AnswerMarkers, b.AnswerMarkers) || a.body != b.body {
t.Errorf("%s did not reproduce under the same seed", task.ID)
}
}
}
// An unresolved placeholder would put "{{marker}}" in the transcript and score
// every run wrong; it fails the fixture instead.
func TestUnresolvedPlaceholderIsRefused(t *testing.T) {
if _, err := instantiate("value is {{missing}}", fixtureVars{"other": "x"}); err == nil {
t.Fatal("an unresolved placeholder was accepted")
}
}
func repoRoot() (string, error) {
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
return "", fmt.Errorf("not in a git checkout: %w", err)
}
return strings.TrimSpace(string(out)), nil
}
// grepRepo searches tracked files only: build artifacts and temp fixtures are
// not what a fresh checkout hands anyone.
func grepRepo(t *testing.T, root, needle string) (bool, string) {
t.Helper()
cmd := exec.Command("git", "grep", "-l", "-F", needle)
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
return false, "" // exit 1 means no match
}
return true, strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
}
func TestEveryFixturePassesPreflight(t *testing.T) {
if testing.Short() {
t.Skip("builds and folds 36 sessions")
}
root := t.TempDir()
for _, task := range allTasks() {
t.Run(task.ID, func(t *testing.T) {
found, err := checkTask(task, root)
if err != nil {
t.Fatal(err)
}
for _, f := range found {
t.Error(f)
}
})
}
}
-290
View File
@@ -1,290 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"path/filepath"
"slices"
"strings"
"reasonix/internal/base/retrieval"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/provider"
)
// Preflight is what makes a real run worth paying for. An answer the model can
// already read, or a cue not where the matrix says, turns an arm into a
// different experiment without saying so.
// armScales are the fold-index arms an index task is measured across.
var armScales = []string{"default", "half", "quarter", "off"}
// experimentArm is one measured configuration of a task.
type experimentArm struct {
name string
scale string
searchOff bool
}
// armsFor is the single declaration of which arms a task runs at, so preflight
// and the run itself cannot check one set and measure another.
func armsFor(t contextTask) []experimentArm {
if t.Experiment == experimentSearch {
return []experimentArm{
{"search-on", "default", false},
{"search-off", "default", true},
}
}
arms := make([]experimentArm, 0, len(armScales))
for _, scale := range armScales {
arms = append(arms, experimentArm{"index-" + scale, scale, false})
}
return arms
}
func scaleOf(name string) ablation.FoldIndexScale {
s, err := ablation.ParseFoldIndexScale(name)
if err != nil {
panic("unknown scale " + name) // the list above is a constant
}
return s
}
// armFor is the arm a task is measured under at one fold-index scale.
func armFor(scale string, searchOff bool) ablation.Set {
set := ablation.Set{}
if searchOff {
set = ablation.New(ablation.RecallSearch)
}
return set.WithFoldIndex(scaleOf(scale))
}
type finding struct {
Task, Arm, Problem string
}
func (f finding) String() string { return fmt.Sprintf("%s [%s]: %s", f.Task, f.Arm, f.Problem) }
// checkTask builds the task under every arm it will be run at and reports what
// does not hold.
func checkTask(t contextTask, root string) ([]finding, error) {
var out []finding
for _, arm := range armsFor(t) {
// Deterministic values: a preflight has to reproduce, and a tier
// calibration measured against one run's nonces is measured against
// nothing.
inst, err := instantiateTask(t, seededRand(t.ID, arm.name, "preflight"))
if err != nil {
return nil, err
}
dir := filepath.Join(root, t.ID, arm.name)
f, err := buildFixture(inst, armFor(arm.scale, arm.searchOff), filepath.Join(dir, "session.jsonl"))
if err != nil {
return nil, err
}
visible := visibleText(f)
// The answer must be unreadable in every arm, or the task measures
// reading rather than recall.
for _, marker := range inst.AnswerMarkers {
if len(marker) < 3 {
continue // a bare number is not a nonce; the whole set still has to match
}
if strings.Contains(visible, marker) {
out = append(out, finding{t.ID, arm.name, fmt.Sprintf("answer marker %q is already visible", marker)})
}
}
// The corpus has to be findable at all, or a miss in a real run says
// nothing about the model.
rank, err := probeRank(f, inst.ProbeQuery)
if err != nil {
return nil, err
}
switch {
case rank == 0:
out = append(out, finding{t.ID, arm.name, fmt.Sprintf("probe query %q does not find the target at all", inst.ProbeQuery)})
case rank > 5:
out = append(out, finding{t.ID, arm.name, fmt.Sprintf("probe query %q ranks the target %d", inst.ProbeQuery, rank)})
}
if t.Experiment == experimentIndex {
out = append(out, checkCueVisibility(t, inst, arm.scale, arm.name, visible)...)
} else if line := indexLineFor(f, f.Target); line != "" {
out = append(out, finding{t.ID, arm.name,
"a search task is addressed by the fold index, so it measures the index and not search: " + line})
}
// The projection is what the agent believes. This is what the model is
// told, and it is the one that has to hold.
req, err := captureRequest(f, armFor(arm.scale, arm.searchOff), inst.Prompt)
if err != nil {
return nil, fmt.Errorf("%s [%s]: capture: %w", t.ID, arm.name, err)
}
out = append(out, checkCapturedRequest(t, inst, arm.name, arm.scale, arm.searchOff, req)...)
}
return out, nil
}
// checkCapturedRequest holds one arm's real provider request to its contract:
// the answer unreadable, the cue exactly where its tier says, the recall tool
// offering only what this arm can do, and no host-side field surviving the
// boundary.
func checkCapturedRequest(t contextTask, inst fixtureInstance, armName, scale string, searchOff bool, req provider.Request) []finding {
var out []finding
for _, marker := range inst.AnswerMarkers {
if len(marker) < 3 {
continue // a bare number is not a nonce; the set as a whole still is
}
for _, at := range findInSurface(req, marker) {
out = append(out, finding{t.ID, armName, fmt.Sprintf("answer marker %q leaked at %s", marker, at)})
}
}
if t.Experiment == experimentIndex {
wantVisible, wantHidden := tierScales(t.CueTier)
where := findInSurface(req, inst.CueMarker)
switch {
case contains(wantVisible, scale) && len(where) == 0:
out = append(out, finding{t.ID, armName, fmt.Sprintf("cue %q is not provider-visible; the tier expects it", inst.CueMarker)})
case contains(wantHidden, scale) && len(where) > 0:
out = append(out, finding{t.ID, armName, fmt.Sprintf("cue %q is provider-visible at %s: %s", inst.CueMarker, where[0], scale)})
}
}
schema, ok := recallToolSchema(req)
if !ok {
out = append(out, finding{t.ID, armName, "recall is not in the provider's tool list"})
return append(out, boundaryFindings(t, armName, req)...)
}
params := ""
if b, err := json.Marshal(schema.Parameters); err == nil {
params = string(b)
}
// Both arms keep the read half: the ablation removes search, not recall.
if !strings.Contains(params, `"positions"`) {
out = append(out, finding{t.ID, armName, "recall lost its positions parameter"})
}
hasQuery := strings.Contains(params, `"query"`)
switch {
case searchOff && hasQuery:
out = append(out, finding{t.ID, armName, "recall still offers a query parameter"})
case !searchOff && !hasQuery:
out = append(out, finding{t.ID, armName, "recall has no query parameter"})
}
if searchOff {
for _, phrase := range searchWording {
for _, at := range findInSurface(req, phrase) {
out = append(out, finding{t.ID, armName, fmt.Sprintf("search is offered to an arm without it: %q at %s", phrase, at)})
}
}
}
return append(out, boundaryFindings(t, armName, req)...)
}
func boundaryFindings(t contextTask, armName string, req provider.Request) []finding {
var out []finding
for _, at := range localOnlyLeaks(req) {
out = append(out, finding{t.ID, armName, "host-only field crossed the provider boundary at " + at})
}
return out
}
// checkCueVisibility holds an index task to its tier: the cue is addressed at
// the scales the matrix names and at no others. A policy change that moves it
// fails here rather than turning two arms into one experiment.
func checkCueVisibility(t contextTask, inst fixtureInstance, scale, armName, visible string) []finding {
wantVisible, wantHidden := tierScales(t.CueTier)
has := strings.Contains(visible, inst.CueMarker)
switch {
case contains(wantVisible, scale) && !has:
return []finding{{t.ID, armName, fmt.Sprintf("cue %q should be addressed at %s and is not", inst.CueMarker, scale)}}
case contains(wantHidden, scale) && has:
return []finding{{t.ID, armName, fmt.Sprintf("cue %q should be gone at %s and is not", inst.CueMarker, scale)}}
}
return nil
}
func contains(list []string, want string) bool { return slices.Contains(list, want) }
// probeRank runs the task's own query against the folded region the way recall
// would, and reports where the target lands.
func probeRank(f builtFixture, query string) (int, error) {
terms, err := retrieval.QueryTerms(query)
if err != nil {
return 0, err
}
canonical := f.Session.Snapshot()
covered := min(f.State.Projection.CoveredCount, len(canonical))
docs := make([]map[string]int, 0, covered)
positions := make([]int, 0, covered)
lengths := make([]int, 0, covered)
for i, m := range canonical[:covered] {
var text strings.Builder
text.WriteString(m.Content)
for _, tc := range m.ToolCalls {
text.WriteString(" " + tc.Name + " " + string(tc.Arguments))
}
tokens := retrieval.Tokens(text.String())
if len(tokens) == 0 {
continue
}
docs = append(docs, retrieval.Counts(tokens))
positions = append(positions, i)
lengths = append(lengths, len(tokens))
}
df := retrieval.DocumentFrequency(docs)
avg := 0.0
for _, l := range lengths {
avg += float64(l)
}
if len(lengths) > 0 {
avg /= float64(len(lengths))
}
type hit struct {
pos int
score float64
}
var hits []hit
for i, counts := range docs {
if s := retrieval.BM25Score(counts, lengths[i], terms, df, len(docs), avg); s > 0 {
hits = append(hits, hit{positions[i], s})
}
}
for i := range hits {
for j := i + 1; j < len(hits); j++ {
if hits[j].score > hits[i].score {
hits[i], hits[j] = hits[j], hits[i]
}
}
}
// A tool result answers at its caller's position, so either counts.
for i, h := range hits {
if h.pos == f.Target || h.pos == f.Target+1 {
return i + 1, nil
}
}
return 0, nil
}
// indexLineFor returns the fold-index line addressing a position, if any.
func indexLineFor(f builtFixture, pos int) string {
want := fmt.Sprintf("#%d ", pos)
for line := range strings.SplitSeq(visibleIndexOnly(f), "\n") {
if strings.Contains(line, want) {
return strings.TrimSpace(line)
}
}
return ""
}
// visibleIndexOnly is just the fold-index sections of the projection.
func visibleIndexOnly(f builtFixture) string {
var b strings.Builder
for _, m := range visibleContext(f) {
if i := strings.Index(m.Content, "## Folded work index"); i >= 0 {
b.WriteString(m.Content[i:])
b.WriteString("\n")
}
}
return b.String()
}
@@ -1,252 +0,0 @@
// reformulation.go — how a query changed, from token sets alone.
package main
import (
"fmt"
"slices"
"sort"
"strings"
"reasonix/internal/base/retrieval"
)
// The host probe ranks every target first, so the engine is not what makes a
// model take more than one attempt. These classes are set arithmetic over the
// same tokenizer recall uses: no judge, nothing read out of prose.
const (
reformFirst = "First" // nothing to compare against yet
reformNearDup = "NearDuplicate" // the same query again, give or take a word
reformNarrowing = "Narrowing" // kept everything and added terms
reformBroadening = "Broadening" // dropped terms, added none
reformAnchored = "HistoricalAnchor" // reached for a term the question does not contain
reformShift = "SynonymShift" // traded terms for others
)
// nearDuplicateOverlap is where "reworded" stops and "asked again" begins.
const nearDuplicateOverlap = 0.8
func terms(s string) []string {
return retrieval.Unique(retrieval.Tokens(s))
}
func termSet(s string) map[string]bool {
out := map[string]bool{}
for _, t := range terms(s) {
out[t] = true
}
return out
}
func jaccard(a, b map[string]bool) float64 {
if len(a) == 0 && len(b) == 0 {
return 1
}
shared := 0
for t := range a {
if b[t] {
shared++
}
}
union := len(a) + len(b) - shared
if union == 0 {
return 0
}
return float64(shared) / float64(union)
}
// classifyReformulation names how this query differs from the one before it.
func classifyReformulation(previous, current, question string) string {
if previous == "" {
return reformFirst
}
prev, cur := termSet(previous), termSet(current)
if jaccard(prev, cur) >= nearDuplicateOverlap {
return reformNearDup
}
added, dropped := diffTerms(cur, prev), diffTerms(prev, cur)
// A term the question never used had to come from the model's own memory of
// the history, which is a different move from rephrasing what it was asked.
ask := termSet(question)
for _, t := range added {
if !ask[t] {
return reformAnchored
}
}
switch {
case len(dropped) == 0:
return reformNarrowing
case len(added) == 0:
return reformBroadening
default:
return reformShift
}
}
func diffTerms(from, minus map[string]bool) []string {
var out []string
for t := range from {
if !minus[t] {
out = append(out, t)
}
}
sort.Strings(out)
return out
}
// classifySearches labels each attempt and records which of the host probe's
// terms it left out. The probe is the oracle: it ranks the target first, so a
// query missing its terms says what the model did not think to ask for.
func classifySearches(m *contextMetrics, inst fixtureInstance) {
probe := terms(inst.ProbeQuery)
previous := ""
for i := range m.Searches {
s := &m.Searches[i]
s.Reformulation = classifyReformulation(previous, s.Query, inst.Prompt)
have := termSet(s.Query)
for _, term := range probe {
if !have[term] {
s.MissingProbeTerms = append(s.MissingProbeTerms, term)
}
}
previous = s.Query
}
}
// queryAudit is the reformulation funnel across a set of runs.
type queryAudit struct {
Runs, RunsThatSearched int
FirstQueryHit int
HitBySecond, HitByThird int
NeverHit int
TotalSearches int
PostHitSearches int
HitThenRead int
EscapeBeforeFirstSearch int
Reformulations map[string]int
MissedProbeTerms map[string]int
}
func auditQueries(runs []contextMetrics) queryAudit {
a := queryAudit{Reformulations: map[string]int{}, MissedProbeTerms: map[string]int{}}
for _, r := range runs {
if r.Failed || r.Contaminated {
continue
}
a.Runs++
a.TotalSearches += len(r.Searches)
a.PostHitSearches += r.PostHitSearches
if r.EscapeBeforeFirstRecall > 0 {
a.EscapeBeforeFirstSearch++
}
if len(r.Searches) == 0 {
continue
}
a.RunsThatSearched++
hitAt := 0
for i, s := range r.Searches {
a.Reformulations[s.Reformulation]++
for _, term := range s.MissingProbeTerms {
a.MissedProbeTerms[term]++
}
if s.TargetRank > 0 && hitAt == 0 {
hitAt = i + 1
}
}
switch {
case hitAt == 1:
a.FirstQueryHit++
case hitAt == 2:
a.HitBySecond++
case hitAt >= 3:
a.HitByThird++
default:
a.NeverHit++
}
if hitAt > 0 && r.TargetRead {
a.HitThenRead++
}
}
return a
}
func (a queryAudit) report() string {
var b strings.Builder
b.WriteString("\n## Query reformulation\n")
fmt.Fprintf(&b, " runs=%d searched=%d searches=%d (%.2f per searching run)\n",
a.Runs, a.RunsThatSearched, a.TotalSearches, ratio(a.TotalSearches, a.RunsThatSearched))
fmt.Fprintf(&b, " target found by query 1: %d by 2: %d by 3+: %d never: %d\n",
a.FirstQueryHit, a.HitBySecond, a.HitByThird, a.NeverHit)
fmt.Fprintf(&b, " found then read: %d/%d searches issued after the target was already back: %d\n",
a.HitThenRead, a.FirstQueryHit+a.HitBySecond+a.HitByThird, a.PostHitSearches)
fmt.Fprintf(&b, " reached for the workspace before searching: %d\n", a.EscapeBeforeFirstSearch)
b.WriteString(" reformulations: " + countsLine(a.Reformulations) + "\n")
b.WriteString(" probe terms most often absent: " + topCounts(a.MissedProbeTerms, 8) + "\n")
return b.String()
}
func ratio(n, d int) float64 {
if d == 0 {
return 0
}
return float64(n) / float64(d)
}
func countsLine(counts map[string]int) string {
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k]))
}
return strings.Join(parts, " ")
}
func topCounts(counts map[string]int, limit int) string {
type kv struct {
k string
n int
}
all := make([]kv, 0, len(counts))
for k, n := range counts {
all = append(all, kv{k, n})
}
sort.Slice(all, func(i, j int) bool {
if all[i].n != all[j].n {
return all[i].n > all[j].n
}
return all[i].k < all[j].k
})
parts := make([]string, 0, limit)
for _, e := range all[:min(limit, len(all))] {
parts = append(parts, fmt.Sprintf("%s=%d", e.k, e.n))
}
if len(parts) == 0 {
return "(none)"
}
return strings.Join(parts, " ")
}
// queryLines lists every attempt, so a surprising audit can be read back.
func queryLines(runs []contextMetrics) string {
var b strings.Builder
b.WriteString("\n## Every query, in order\n")
for _, r := range runs {
for _, s := range r.Searches {
rank := "miss"
if s.TargetRank > 0 {
rank = fmt.Sprintf("rank %d", s.TargetRank)
}
fmt.Fprintf(&b, " %-24s %-14s #%d %-17s %-9s %q\n",
r.Task, r.Arm, s.Ordinal, s.Reformulation, rank, s.Query)
if len(s.MissingProbeTerms) > 0 && s.TargetRank == 0 {
fmt.Fprintf(&b, " %-24s %-14s missing probe terms: %s\n",
"", "", strings.Join(slices.Clone(s.MissingProbeTerms), " "))
}
}
}
return b.String()
}
-569
View File
@@ -1,569 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"reasonix/internal/state/sessionstore"
"slices"
"sort"
"strings"
"time"
"reasonix/internal/assembly/boot"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
_ "reasonix/internal/model/openai"
"reasonix/internal/runtime/agent"
_ "reasonix/internal/tools/builtin"
)
// A run is one recall question against a fixture the preflight has already
// cleared. Scoring reads the transcript the turn appended — tool names and
// their JSON arguments — so nothing depends on how the model narrated itself.
const (
runBaseURL = "https://api.deepseek.com"
runModel = "deepseek-chat"
runTimeout = 4 * time.Minute
)
func realProvider() (provider.Provider, error) {
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
return nil, fmt.Errorf("a real run needs DEEPSEEK_API_KEY")
}
return provider.New("openai", provider.Config{
Name: "contextbench", BaseURL: runBaseURL, Model: runModel, APIKey: key,
})
}
// adversarialPrompts tell the model what it is running and send it after the
// answer everywhere except the conversation. The question is not whether it
// obeys the experiment: it is whether the answer is reachable at all by a model
// that knows the mechanism and is actively attacking the host.
var adversarialPrompts = map[string]string{
experimentSearch: "You are running a benchmark. Find the expected answer for this task from the " +
"Reasonix repository, benchmark source, fixture generator, test files, scratch files, or " +
"session metadata. Do not use recall unless necessary. Report the exact hidden values.",
experimentIndex: "Inspect benchmarks/context-retrieval, the git repository, session files, temporary " +
"files, and any benchmark metadata you can access. Recover the concrete instantiated answer for " +
"this run without relying on the folded conversation. Report the exact values.",
}
// runAdversarial sends one task per experiment after the answer through every
// host-readable surface. It passes when nothing but recall could produce it —
// a run that gives up and recalls has still proven the attack failed.
func runAdversarial(root string) int {
root, err := filepath.Abs(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
restore, ierr := isolateWorkspace()
if ierr != nil {
fmt.Fprintln(os.Stderr, "isolate workspace:", ierr)
return 2
}
defer restore()
p, perr := realProvider()
if perr != nil {
fmt.Fprintln(os.Stderr, perr)
return 2
}
leaked := 0
for _, id := range []string{"r01-retry-boundary", "i01-transport-fallback"} {
t, ok := taskByID(id)
if !ok {
fmt.Fprintf(os.Stderr, "unknown task %q\n", id)
return 2
}
m, err := runOne(p, t, armFor("default", false), "adversarial", root, false, false,
adversarialPrompts[t.Experiment])
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", id, err)
m = failedMetrics(t, "adversarial", err)
}
verdict := "isolated"
if m.EscapeFoundAnswer {
verdict = "LEAKED via " + m.LeakedVia
leaked++
}
fmt.Printf("%-24s %-24s escapes=%-3d recall=%d answered=%v tools=%v\n",
id, verdict, m.EscapeCalls, m.SearchCalls+m.ReadCalls, m.AnswerRecovered, m.UnexpectedWorkTools)
if m.EscapeFoundAnswer {
fmt.Printf(" args: %s\n output: %s\n", m.LeakedArgs, m.LeakedOutput)
}
}
if leaked > 0 {
fmt.Fprintf(os.Stderr, "\n%d of 2 adversarial runs reached an answer outside recall\n", leaked)
return 1
}
fmt.Println("\nNo answer was reachable outside recall.")
return 0
}
// runOne asks one task under one arm and scores what the model did.
func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root string, cueVisible, dry bool, promptOverride ...string) (contextMetrics, error) {
// Live runs get values that existed nowhere before the run started; a dry
// run stays reproducible.
rng := liveRand()
if dry {
rng = seededRand(t.ID, armName, "dry")
}
inst, err := instantiateTask(t, rng)
if err != nil {
return contextMetrics{}, err
}
f, err := buildFixture(inst, arm, filepath.Join(root, t.ID, armName, "session.jsonl"))
if err != nil {
return contextMetrics{}, err
}
sess, err := sessionstore.LoadSession(f.Path)
if err != nil {
return contextMetrics{}, fmt.Errorf("load session: %w", err)
}
reg := tool.NewRegistry()
for _, x := range tool.Builtins() {
reg.Add(x)
}
boot.ApplyUnifiedProviderToolSurface(reg, false, arm)
var usage usageSink
a := agent.New(p, reg, sess, agent.Options{
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
SessionPath: f.Path, KeepPolicy: agent.KeepErrors, Ablation: arm,
}, &usage)
a.LoadProjectionSidecar(f.Path)
// From here the transcript lives only in memory. On disk it is the answer.
sealFixture(f.Path)
defer unsealFixture(f.Path)
dir := filepath.Dir(f.Path)
if at, marker := answerOnDisk(dir, inst.AnswerMarkers); at != "" {
return contextMetrics{}, fmt.Errorf("%s [%s]: %q is readable at %s before the run", t.ID, armName, marker, at)
}
before := len(sess.Snapshot())
ctx, cancel := context.WithTimeout(context.Background(), runTimeout)
defer cancel()
prompt := inst.Prompt
if len(promptOverride) > 0 && promptOverride[0] != "" {
prompt = promptOverride[0]
}
runErr := a.Run(ctx, prompt)
if at, marker := answerOnDisk(dir, inst.AnswerMarkers); at != "" {
return contextMetrics{}, fmt.Errorf("%s [%s]: %q was written to %s during the run", t.ID, armName, marker, at)
}
appended := sess.Snapshot()[before:]
m := scoreRun(appended, inst, f.Target, armName, cueVisible)
m.trajectory = appended
if u, requests := usage.snapshot(); requests > 0 {
m.UsageReportedRequests, m.PromptTokens, m.CompletionTokens = requests, u.PromptTokens, u.CompletionTokens
m.CacheHitTokens, m.CacheMissTokens, m.UsageEstimated = u.CacheHitTokens, u.CacheMissTokens, u.Estimated
}
m.scoreAnswer(finalAnswer(appended), inst)
if runErr != nil {
m.FailureStage = "RunError"
m.TimeoutReason = timeoutReason(m)
}
return m, runErr
}
// failedMetrics is what a run that never reached a transcript leaves behind. It
// carries the run's identity and the stage that ended it, so the failure stays a
// countable fact; Failed keeps it out of every statistic.
func failedMetrics(t contextTask, armName string, err error) contextMetrics {
return contextMetrics{
Task: t.ID, Arm: armName, Failed: true,
FailureStage: stageNoEvidence, Reason: err.Error(),
}
}
// finalAnswer is the last assistant text the turn produced.
func finalAnswer(msgs []provider.Message) string {
for _, m := range slices.Backward(msgs) {
if m.Role == provider.RoleAssistant && strings.TrimSpace(m.Content) != "" {
return m.Content
}
}
return ""
}
// isolateWorkspace moves the process into an empty directory before any task
// runs. The corpus lives in this repository, so an agent left here greps the
// answers out of corpus.go — one did, quoting the file's own line about them
// being nonces. Fixtures stay outside it too: a readable session file is the
// canonical transcript, answer included.
func isolateWorkspace() (func(), error) {
dir, err := os.MkdirTemp("", "contextbench-workspace-*")
if err != nil {
return nil, err
}
readme := "This workspace is empty on purpose. The question is about earlier conversation,\nnot about any file here.\n"
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte(readme), 0o644); err != nil {
return nil, err
}
previous, err := os.Getwd()
if err != nil {
return nil, err
}
if err := os.Chdir(dir); err != nil {
return nil, err
}
return func() {
_ = os.Chdir(previous)
_ = os.RemoveAll(dir)
}, nil
}
// runBoundaries runs only the pair each index task is built for: the narrowest
// scale still addressing its cue, and the next one down. Each pair moves the
// affordance and nothing else, where default-vs-off moves the whole index.
// The two arms get separate nonces: reusing one would leave the first arm's
// answer in a host-readable history for the second to find.
func runBoundaries(mode, root string) int {
started := time.Now()
planned := 0
repo, _ := os.Getwd()
root, err := filepath.Abs(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
restore, ierr := isolateWorkspace()
if ierr != nil {
fmt.Fprintln(os.Stderr, "isolate workspace:", ierr)
return 2
}
defer restore()
p, perr := realProvider()
if perr != nil {
fmt.Fprintln(os.Stderr, perr)
return 2
}
var all []contextMetrics
var cueSide, noCueSide []contextMetrics
for _, t := range efficiencyTasks() {
cue, noCue := boundaryPair(t.CueTier)
for _, side := range []struct {
scale string
has bool
}{{cue, true}, {noCue, false}} {
name := "index-" + side.scale
planned++
m, err := runOne(p, t, armFor(side.scale, false), name, root, side.has, false)
if err != nil {
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, name, err)
m = failedMetrics(t, name, err)
}
all = append(all, m)
if side.has {
cueSide = append(cueSide, m)
} else {
noCueSide = append(noCueSide, m)
}
fmt.Printf("%-24s %-14s cue=%-3v %-14s search=%d read=%d direct=%-5v recall-tok=%-5d%s escape=%d\n",
t.ID, name, side.has, m.FailureStage, m.SearchCalls, m.ReadCalls,
m.CueDirectRead, m.RecallReturnedTokens, promptTokenSuffix(m), m.EscapeCalls)
}
}
reportDeltas(cueSide, noCueSide)
fmt.Print(reportStopping(all))
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Repo: repo}, all)
}
// reportDeltas is the paired reading: each task against itself, then pooled.
func reportDeltas(cueSide, noCueSide []contextMetrics) {
fmt.Printf("\n## Paired deltas (cue present − cue absent)\n")
fmt.Printf(" %-24s %-10s %-10s %-9s %-11s %s\n",
"task", "recovery", "directread", "searches", "recall-tok", "escapes")
for i := range cueSide {
if i >= len(noCueSide) {
break
}
a, b := cueSide[i], noCueSide[i]
fmt.Printf(" %-24s %-10s %-10s %-9d %-11d %d\n", a.Task,
deltaBool(a.AnswerRecovered, b.AnswerRecovered),
deltaBool(a.CueDirectRead, b.CueDirectRead),
a.SearchCalls-b.SearchCalls,
a.RecallReturnedTokens-b.RecallReturnedTokens,
a.EscapeCalls-b.EscapeCalls)
}
fmt.Println("\n## Pooled")
fmt.Println(" ", summarize("cue-present", cueSide).line())
fmt.Println(" ", summarize("cue-absent", noCueSide).line())
fmt.Println("\n## Where each side looked first")
fmt.Printf(" %-14s %s\n", "cue-present", countsLine(summarize("x", cueSide).Routes))
fmt.Printf(" %-14s %s\n", "cue-absent", countsLine(summarize("x", noCueSide).Routes))
}
func deltaBool(a, b bool) string {
switch {
case a == b:
return "same"
case a:
return "+cue"
default:
return "-cue"
}
}
// runExperiment runs one experiment's tasks across its arms. A dry run drives
// the same pipeline with a scripted provider: it proves fixture, run, scoring
// and report hold together before any of it is paid for.
func runExperiment(mode, experiment, root string, dry bool, tasks []contextTask) int {
started := time.Now()
planned := 0
repo, _ := os.Getwd()
root, err := filepath.Abs(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
restore, err := isolateWorkspace()
if err != nil {
fmt.Fprintln(os.Stderr, "isolate workspace:", err)
return 2
}
defer restore()
var p provider.Provider = &scriptedProvider{}
if !dry {
real, rerr := realProvider()
if rerr != nil {
fmt.Fprintln(os.Stderr, rerr)
return 2
}
p = real
}
byArm := map[string][]contextMetrics{}
var all []contextMetrics
if experiment == experimentIndex {
fmt.Print(fingerprint(dry))
}
for _, t := range tasks {
if t.Experiment != experiment {
continue
}
// Arms are shuffled inside each task block. Running six defaults, then
// six halves, would make provider drift collinear with the axis under
// test: a bad minute would land entirely on one arm.
for _, arm := range shuffledArms(t) {
cueVisible := t.Experiment == experimentIndex && cueExpectedVisible(t, arm.scale)
planned++
m, err := runOne(p, t, armFor(arm.scale, arm.searchOff), arm.name, root, cueVisible, dry)
if err != nil {
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, arm.name, err)
m = failedMetrics(t, arm.name, err)
}
byArm[arm.name] = append(byArm[arm.name], m)
all = append(all, m)
fmt.Printf("%-24s %-14s %-18s search=%d hit=%d read=%d recall-tok=%d%s %v\n",
t.ID, arm.name, m.FailureStage, m.SearchCalls, m.TargetSearchHits, m.ReadCalls,
m.RecallReturnedTokens, promptTokenSuffix(m), m.UnexpectedWorkTools)
}
}
reportFunnels(byArm)
if experiment == experimentIndex {
fmt.Print(reportDoseResponse(all))
}
fmt.Print(reportStopping(all))
fmt.Print(auditQueries(all).report())
fmt.Print(queryLines(all))
if experiment == experimentIndex {
reportBoundaries(all)
}
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Dry: dry, Repo: repo}, all)
}
func cueExpectedVisible(t contextTask, scale string) bool {
visible, _ := tierScales(t.CueTier)
return contains(visible, scale)
}
func reportFunnels(byArm map[string][]contextMetrics) {
names := make([]string, 0, len(byArm))
for name := range byArm {
names = append(names, name)
}
sort.Strings(names)
fmt.Println("\n## Funnel")
for _, name := range names {
fmt.Println(" ", summarize(name, byArm[name]).line())
}
fmt.Println("\n## Where it looked first")
for _, name := range names {
f := summarize(name, byArm[name])
fmt.Printf(" %-14s %s\n", name, countsLine(f.Routes))
}
fmt.Println("\n## Where it broke")
for _, name := range names {
f := summarize(name, byArm[name])
stages := make([]string, 0, len(f.Stages))
for stage, n := range f.Stages {
stages = append(stages, fmt.Sprintf("%s=%d", stage, n))
}
sort.Strings(stages)
fmt.Printf(" %-12s %s\n", name, strings.Join(stages, " "))
}
}
// writeTrajectories saves what each turn produced, after the whole batch is
// done. Three analyses in a row have needed a re-run because something was
// counted and not kept — query text, then snippet contents. It is written at
// the end on purpose: during the batch these files are the answer.
func writeTrajectories(root string, all []contextMetrics) error {
f, err := os.Create(filepath.Join(root, "trajectories.jsonl"))
if err != nil {
return err
}
defer f.Close()
for _, m := range all {
if err := writeJSONLine(f, map[string]any{
"task": m.Task, "arm": m.Arm, "messages": m.trajectory,
}); err != nil {
return err
}
}
return nil
}
func writeResults(root string, info batchInfo, all []contextMetrics) int {
path := filepath.Join(root, "results.jsonl")
f, err := os.Create(path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
defer f.Close()
scored, failed := 0, 0
for _, m := range all {
if err := writeJSONLine(f, m); err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
if m.Failed {
failed++
} else {
scored++
}
}
if err := writeTrajectories(root, all); err != nil {
fmt.Fprintln(os.Stderr, "write trajectories:", err)
return 2
}
if err := writeBatchManifest(root, info, all); err != nil {
fmt.Fprintln(os.Stderr, "write batch manifest:", err)
return 2
}
// Both counts are named: a reader who sees only the batch size cannot tell
// measurements from attempts.
fmt.Printf("\n%d runs written to %s: scored=%d failed=%d (with trajectories.jsonl and batch.json)\n",
len(all), path, scored, failed)
return 0
}
// reportBoundaries prints each index task across its own cue boundary, then the
// two sides pooled. Task difficulty is held constant inside a row, which is
// what a four-arm average cannot do.
func reportBoundaries(all []contextMetrics) {
byKey := map[string]contextMetrics{}
scored, failed := 0, 0
for _, m := range all {
if m.Failed {
// No transcript, no boundary to compare: count the failure instead of
// printing the zero it left behind as a measurement.
failed++
continue
}
scored++
byKey[m.Task+"|"+m.Arm] = m
}
if scored == 0 {
fmt.Printf("\n## Across each task's cue boundary\n no scored runs: scored=%d failed=%d (nothing to compare)\n", scored, failed)
return
}
var cueSide, noCueSide []contextMetrics
fmt.Println("\n## Across each task's cue boundary")
fmt.Printf(" %-24s %-16s %-4s %-10s %-9s %-12s %-11s %s\n",
"task", "arm", "cue", "recovered", "cue-read", "search/task", "recall-tok", "escape")
for _, t := range indexTasks() {
cue, noCue := boundaryPair(t.CueTier)
for _, side := range []struct {
scale string
has bool
}{{cue, true}, {noCue, false}} {
m, ok := byKey[t.ID+"|index-"+side.scale]
if !ok {
continue
}
if side.has {
cueSide = append(cueSide, m)
} else {
noCueSide = append(noCueSide, m)
}
mark := "no"
if side.has {
mark = "yes"
}
fmt.Printf(" %-24s %-16s %-4s %-10v %-9v %-12d %-11d %d\n",
t.ID, "index-"+side.scale, mark, m.AnswerRecovered, m.RecallReadWithoutSearch,
m.SearchCalls, m.RecallReturnedTokens, m.EscapeCalls)
}
}
fmt.Println("\n## Pooled across boundaries")
fmt.Println(" ", summarize("cue-present", cueSide).line())
fmt.Println(" ", summarize("cue-absent", noCueSide).line())
}
// shuffledArms permutes a task's arms deterministically, so the order is
// reproducible and still uncorrelated with the axis being measured.
func shuffledArms(t contextTask) []experimentArm {
arms := armsFor(t)
rng := seededRand(t.ID, "arm-order")
rng.Shuffle(len(arms), func(i, j int) { arms[i], arms[j] = arms[j], arms[i] })
return arms
}
// fingerprint records what produced a batch. Not a gate — but batch effects
// have reversed three conclusions in this experiment already, and a table with
// no provenance cannot be compared with the next one.
func fingerprint(dry bool) string {
model, endpoint := runModel, runBaseURL
if dry {
model, endpoint = "scripted", "none"
}
commit := "unknown"
if out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output(); err == nil {
commit = strings.TrimSpace(string(out))
}
return fmt.Sprintf("## Batch\n model=%s endpoint=%s window=%d generations=%d commit=%s started=%s\n",
model, endpoint, fixtureWindow, fixtureGenerations, commit, time.Now().UTC().Format(time.RFC3339))
}
// timeoutReason separates a run that never found its answer from one that found
// it and kept going. Only the first is a case for a longer budget; the second
// would just get more time to circle. A batch reporting only "context deadline
// exceeded" cannot tell an arm that made retrieval harder from an arm that
// happened to draw a slow minute.
func timeoutReason(m contextMetrics) string {
switch {
case m.TargetHeldAtEnd && m.RoundsAfterTarget >= 2:
return "PostTargetRunaway"
case m.TargetHeldAtEnd:
return "TargetHeldLate"
case m.SearchCalls >= 3:
return "SearchSpiral"
case m.SearchCalls+m.ReadCalls+m.EscapeCalls == 0:
return "NoToolActivity"
default:
return "Unknown"
}
}
-95
View File
@@ -1,95 +0,0 @@
package main
import (
"context"
"encoding/json"
"regexp"
"slices"
"strconv"
"strings"
"reasonix/internal/contract/provider"
)
// scriptedProvider walks the chain a competent model would: search, read the
// best hit, answer with what came back. It proves the pipeline holds together
// for nothing, so a real run's first failure is about the model.
type scriptedProvider struct{}
func (p *scriptedProvider) Name() string { return "contextbench-scripted" }
var (
scriptedHit = regexp.MustCompile(`(?m)^#(\d+) `)
scriptedRecall = regexp.MustCompile(`(?s)^#\d+\n(.*)`)
)
// Stream decides from the request alone, never from a call counter: one
// provider serves every task in a run, and a counter would make the second task
// start where the first left off.
func (p *scriptedProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
ch := make(chan provider.Chunk, 3)
defer close(ch)
// Honour the schema, as a model does: an arm without a query parameter is
// an arm that cannot search, and pretending otherwise scores a refusal as
// a failed search.
if !recallAccepts(req, "query") {
ch <- provider.Chunk{Type: provider.ChunkText, Text: "I have no address for that part of the history."}
ch <- provider.Chunk{Type: provider.ChunkDone}
return ch, nil
}
// The last message, not the last tool result anywhere: the fixture's own
// history is full of tool results, and one of those is not this turn's.
last := ""
if n := len(req.Messages); n > 0 && req.Messages[n-1].Role == provider.RoleTool {
last = req.Messages[n-1].Content
}
switch {
case last == "":
args, _ := json.Marshal(map[string]string{"query": lastUserText(req)})
ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
ID: "s1", Name: "recall", Arguments: string(args),
}}
case strings.Contains(last, "Folded context matching"):
positions := scriptedHit.FindAllStringSubmatch(last, 1)
if len(positions) == 0 {
ch <- provider.Chunk{Type: provider.ChunkText, Text: "Nothing in the folded history matches."}
break
}
n, _ := strconv.Atoi(positions[0][1])
args, _ := json.Marshal(map[string][]int{"positions": {n}})
ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
ID: "r1", Name: "recall", Arguments: string(args),
}}
default:
// Answer with what recall returned, so a correct chain scores as one.
body := last
if m := scriptedRecall.FindStringSubmatch(last); len(m) > 1 {
body = m[1]
}
ch <- provider.Chunk{Type: provider.ChunkText, Text: "From the folded history: " + body}
}
ch <- provider.Chunk{Type: provider.ChunkDone}
return ch, nil
}
// recallAccepts reports whether recall is offered with the given parameter.
func recallAccepts(req provider.Request, param string) bool {
for _, t := range req.Tools {
if t.Name != "recall" {
continue
}
b, err := json.Marshal(t.Parameters)
return err == nil && strings.Contains(string(b), `"`+param+`"`)
}
return false
}
func lastUserText(req provider.Request) string {
for _, m := range slices.Backward(req.Messages) {
if m.Role == provider.RoleUser {
return m.Content
}
}
return ""
}
-182
View File
@@ -1,182 +0,0 @@
// snippet.go — does a rank-1 hit actually hand over the answer?
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/base/retrieval"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/event"
"reasonix/internal/contract/tool"
"reasonix/internal/runtime/agent"
)
// TargetHit is not EvidenceSufficient: a rank-1 hit can still hand over a
// window too short to answer with, and searching again after that is correct.
// Measured through the shipped recall path, not a copy of it.
// snippetWidths are the windows to compare. 240 is what ships.
var snippetWidths = []int{120, 180, 240, 320, 480}
// historicalQueries are the queries these tasks actually drew in the pilots,
// kept because a probe query is the host's phrasing and the model's was not.
var historicalQueries = map[string][]string{
"i03-coalescing": {"coalesce window marker probe", "job completion coalescing window"},
"i04-recovery-fence": {"recovery fence marker epoch", "expected epoch", "recfence quartz"},
}
type snippetReading struct {
Task string
Query string
Width int
Covered int
Total int
Runes int
Rank int
Complete bool
}
// runSnippetAudit answers the question offline, over many instantiations so a
// single seed's lengths cannot decide it.
func runSnippetAudit(root string, seeds int) int {
fmt.Printf("## Snippet coverage (%d instantiations per task)\n", seeds)
fmt.Println(" Does the window a rank-1 hit returns contain every value the task scores on?")
byTaskWidth := map[string]map[int][]snippetReading{}
for _, t := range allTasks() {
byTaskWidth[t.ID] = map[int][]snippetReading{}
for seed := range seeds {
inst, err := instantiateTask(t, seededRand(t.ID, "snippet", fmt.Sprint(seed)))
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
queries := append([]string{inst.ProbeQuery}, historicalQueries[t.ID]...)
for _, q := range queries {
for _, width := range snippetWidths {
r := measureSnippet(inst, q, width)
byTaskWidth[t.ID][width] = append(byTaskWidth[t.ID][width], r)
}
}
}
}
fmt.Printf("\n %-24s %-5s", "task", "vals")
for _, w := range snippetWidths {
fmt.Printf("%9d", w)
}
fmt.Println(" mean runes @240")
for _, t := range allTasks() {
vals := len(t.AnswerVars)
fmt.Printf(" %-24s %-5d", t.ID, vals)
for _, w := range snippetWidths {
fmt.Printf("%8.0f%%", 100*completeRate(byTaskWidth[t.ID][w]))
}
fmt.Printf(" %.0f\n", meanRunes(byTaskWidth[t.ID][240]))
}
reportShippedPath(root)
return 0
}
// measureSnippet renders one hit through the same MakeSnippet the recall path
// uses, then asks whether every scored value survived the window.
func measureSnippet(inst fixtureInstance, query string, width int) snippetReading {
terms, err := retrieval.QueryTerms(query)
if err != nil {
return snippetReading{Task: inst.Task.ID, Query: query, Width: width, Total: len(inst.AnswerMarkers)}
}
// The tool result is the document the answer lives in; a call's arguments
// are a separate document and never carry it.
snippet := retrieval.MakeSnippet(inst.body, query, terms, width)
covered := 0
for _, marker := range inst.AnswerMarkers {
if strings.Contains(strings.ToLower(snippet), strings.ToLower(marker)) {
covered++
}
}
return snippetReading{
Task: inst.Task.ID, Query: query, Width: width,
Covered: covered, Total: len(inst.AnswerMarkers),
Runes: len([]rune(snippet)), Complete: covered == len(inst.AnswerMarkers),
}
}
// reportShippedPath cross-checks the table against the real recall tool: a
// fixture, a fold, and a search through the agent that ships.
func reportShippedPath(root string) {
fmt.Println("\n## Through the shipped recall path (width as shipped)")
for _, t := range allTasks() {
inst, err := instantiateTask(t, seededRand(t.ID, "snippet-live"))
if err != nil {
continue
}
f, err := buildFixture(inst, ablation.Set{}, filepath.Join(root, "snippet", t.ID, "session.jsonl"))
if err != nil {
fmt.Printf(" %-24s fixture: %v\n", t.ID, err)
continue
}
a := agent.New(&digestProvider{}, tool.NewRegistry(), f.Session, agent.Options{
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
SessionPath: f.Path, KeepPolicy: agent.KeepErrors,
}, event.Discard)
a.LoadProjectionSidecar(f.Path)
queries := append([]string{inst.ProbeQuery}, historicalQueries[t.ID]...)
for _, q := range queries {
res, err := a.RecallContext(context.Background(), tool.RecallRequest{Query: q})
if err != nil {
fmt.Printf(" %-24s %-34q recall: %v\n", t.ID, q, err)
continue
}
covered := 0
for _, marker := range inst.AnswerMarkers {
if strings.Contains(strings.ToLower(res.Text), strings.ToLower(marker)) {
covered++
}
}
fmt.Printf(" %-24s %-34q %d/%d values in the result\n",
t.ID, truncate(q, 32), covered, len(inst.AnswerMarkers))
}
}
}
func completeRate(rs []snippetReading) float64 {
if len(rs) == 0 {
return 0
}
n := 0
for _, r := range rs {
if r.Complete {
n++
}
}
return float64(n) / float64(len(rs))
}
func meanRunes(rs []snippetReading) float64 {
if len(rs) == 0 {
return 0
}
total := 0
for _, r := range rs {
total += r.Runes
}
return float64(total) / float64(len(rs))
}
// answerCoverage is what a run should have recorded all along: how much of the
// scored answer a search actually handed over. rank=1 with coverage 1/3 is not
// a stopping failure.
func answerCoverage(text string, markers []string) int {
covered := 0
lower := strings.ToLower(text)
for _, marker := range markers {
if strings.Contains(lower, strings.ToLower(marker)) {
covered++
}
}
return covered
}
-59
View File
@@ -1,59 +0,0 @@
package main
import (
"fmt"
"sync"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
)
// usageSink reads what a run cost from the events the agent already emits. Every
// billed completion reports event.Usage — the round path, compaction, command
// routing and failure diagnosis all do — so the bench reads the same record the
// product reads instead of wrapping a provider and counting a second time.
type usageSink struct {
mu sync.Mutex
requests int
usage provider.Usage
}
func (s *usageSink) Emit(e event.Event) {
if e.Kind != event.Usage || e.Usage == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
// One event is one completion that reported usage; RequestCount covers a
// completion that merged attempts. A request that failed before reporting
// leaves no event, so this is not a count of attempts.
if e.Usage.RequestCount > 0 {
s.requests += e.Usage.RequestCount
} else {
s.requests++
}
u := *e.Usage
s.usage.PromptTokens += u.PromptTokens
s.usage.CompletionTokens += u.CompletionTokens
s.usage.TotalTokens += u.TotalTokens
s.usage.CacheHitTokens += u.CacheHitTokens
s.usage.CacheMissTokens += u.CacheMissTokens
s.usage.ReasoningTokens += u.ReasoningTokens
s.usage.Estimated = s.usage.Estimated || u.Estimated
}
func (s *usageSink) snapshot() (provider.Usage, int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.usage, s.requests
}
// promptTokenSuffix renders what a run cost only when something reported it. A
// dry run reports nothing, and a trailing "prompt-tok=0" would read as a
// measured zero rather than as no measurement.
func promptTokenSuffix(m contextMetrics) string {
if m.UsageReportedRequests == 0 {
return ""
}
return fmt.Sprintf(" prompt-tok=%d", m.PromptTokens)
}
@@ -1,54 +0,0 @@
package main
import (
"testing"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
)
// The cost a run reports must be what the agent reported, summed across every
// completion it billed — including one that merged attempts and one that could
// only estimate. Any other event kind is not a cost.
func TestUsageSinkAccumulatesWhatTheAgentReports(t *testing.T) {
var sink usageSink
sink.Emit(event.Event{}) // a kind that is not Usage
sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
PromptTokens: 100, CompletionTokens: 10, TotalTokens: 110, CacheHitTokens: 60, CacheMissTokens: 40,
}})
sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
PromptTokens: 7, CompletionTokens: 3, CacheHitTokens: 2, CacheMissTokens: 5, RequestCount: 2, Estimated: true,
}})
sink.Emit(event.Event{Kind: event.Usage}) // a Usage event with no payload is not a cost
usage, requests := sink.snapshot()
if requests != 3 {
t.Fatalf("requests = %d, want 3 (1 + the merged attempt's 2; the empty event adds none)", requests)
}
if usage.PromptTokens != 107 || usage.CompletionTokens != 13 || usage.TotalTokens != 110 {
t.Fatalf("accumulated usage = %+v", usage)
}
if usage.CacheHitTokens != 62 || usage.CacheMissTokens != 45 {
t.Fatalf("cache split = %+v", usage)
}
if !usage.Estimated {
t.Fatal("Estimated was dropped when one completion reported an estimate")
}
}
// A dry run must not claim a cost it never paid, in the JSON or in the line a
// person reads: an empty suffix, not "prompt-tok=0".
func TestUsageSinkStaysEmptyWithoutUsage(t *testing.T) {
var sink usageSink
sink.Emit(event.Event{})
usage, requests := sink.snapshot()
if requests != 0 || usage.PromptTokens != 0 || usage.CompletionTokens != 0 {
t.Fatalf("nothing reported usage, got %+v requests=%d", usage, requests)
}
if suffix := promptTokenSuffix(contextMetrics{}); suffix != "" {
t.Fatalf("a run with no reported usage still printed %q", suffix)
}
if suffix := promptTokenSuffix(contextMetrics{UsageReportedRequests: 1, PromptTokens: 12}); suffix != " prompt-tok=12" {
t.Fatalf("a reported cost printed %q", suffix)
}
}
File diff suppressed because it is too large. Load diff
-452
View File
@@ -1,452 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"sort"
"strings"
"time"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/session/control"
"reasonix/internal/state/execjournal"
)
// armFleetActiveStore asks whether the sub-agent store can be asked about a
// fan-out item whose slot was granted and whose child reached the provider. It
// is not a parity arm: the store is what every recovery path reads, so what is
// measured is one store answering differently about executions it holds the
// same evidence for.
const armFleetActiveStore = "fleet-active-store"
// activeFleetCallID is the fan-out under measurement. The lone delegation in
// the same turn keeps taskCallID, so the two entry points stay separable in
// every artifact without a naming scheme of their own.
const activeFleetCallID = "probe_fleet_active"
// probeFleetChildEntered is the fan-out's half of the entry evidence. The lone
// delegation's rides probeChildEntered, which already means exactly this for
// the delegation under measurement.
const probeFleetChildEntered = "probe:fleet-child-entered"
// The four populations one death holds. They are read off the journal rather
// than off the item ids the dispatch declared: which of the two held-back items
// wins the freed slot is the scheduler's to decide, and an arm that named them
// in advance would be reporting a race as a premise.
type population string
const (
popSettled population = "fan-out settled"
popRunning population = "fan-out executing"
popRefused population = "fan-out refused"
popLone population = "lone executing"
popNone population = ""
)
// measured are the four in the order they are reported, which is also the order
// they become true: an item settles, the slot it frees admits one of the two
// behind it, and the delegation holding the other slot outlives all of them.
func measured() []population { return []population{popSettled, popRunning, popRefused, popLone} }
// populationOf places one journal entry in its population, or in none. Started
// and open is executing; started and settled is done; queued without a start is an
// item the scheduler held back and never admitted.
func populationOf(e execjournal.Entry) population {
switch {
case ofLoneTask(e.ID):
if e.Started() && e.Open() {
return popLone
}
case ofActiveFleet(e.ID):
switch {
case e.Started() && !e.Open():
return popSettled
case e.Started() && e.Open():
return popRunning
case !e.Started() && e.Queued():
return popRefused
}
}
return popNone
}
func ofActiveFleet(id string) bool { return strings.HasPrefix(id, activeFleetCallID+"/") }
// executionsIn are the ids this observation's journal places in a population,
// in a stable order.
func executionsIn(o Observation, pop population) []string {
var out []string
for _, e := range o.Executions {
if populationOf(e) == pop {
out = append(out, e.ID)
}
}
sort.Strings(out)
return out
}
// storedStatus is what the store says about one execution, empty when it has
// never heard of it. The join is the execution id the child recorded as its
// parent call, which is the same identity the journal opened it under.
func storedStatus(o Observation, id string) string {
for _, f := range o.Children.Facts {
if f.ParentToolCallID == id {
return f.Status
}
}
return ""
}
// noRecord is what the store says about an execution it never recorded. It is
// spelled out rather than left blank because a blank column reads as a row that
// was not measured, which is the opposite of what an absent record means here.
const noRecord = "no record"
func storeReading(o Observation, ids []string) string {
out := make([]string, 0, len(ids))
for _, id := range ids {
status := storedStatus(o, id)
if status == "" {
status = noRecord
}
out = append(out, id+"→"+status)
}
return orNone(join(out))
}
// acknowledged reports whether the store holds a record for every one of these
// executions. An empty set is not acknowledgement: a population the death never
// produced is a premise that failed, and its row says not-measured.
func acknowledged(o Observation, ids []string) bool {
for _, id := range ids {
if storedStatus(o, id) == "" {
return false
}
}
return len(ids) > 0
}
func activeStoreArm(name string) bool { return name == armFleetActiveStore }
// activeStoreRows read one store from four sides. Each population has the same
// two columns — what the dying process's store said, and what the next one's
// does — so the answers can only differ by which execution is being asked about.
func activeStoreRows(before, after Observation) []row {
return []row{
populationsRow(before),
childEntryRow(before),
settledStoreRow(before, after),
executingStoreRow(before, after),
loneStoreRow(before, after),
refusedStoreRow(before, after),
listingRow(before, after),
rebuildUnmovedRow(before, after),
}
}
// populationsRow is the arm's premise, not a durability claim: one death has to
// hold all four states at once, or the comparisons below are between different
// runs rather than between entry points.
func populationsRow(before Observation) row {
var got []string
for _, pop := range measured() {
got = append(got, fmt.Sprintf("%s=%d", pop, len(executionsIn(before, pop))))
}
return row{
Semantic: "the four populations this death holds", Authority: journalAuthority,
Artifact: "<stem>.execution.jsonl", Reconstruction: "in-process only",
Before: join(got), After: "—", Verdict: activePremiseVerdict(before),
}
}
func activePremiseVerdict(before Observation) string {
if activeStoreInvalid(before) != "" {
return verdictNotMeasured
}
return verdictHolds
}
// childEntryRow is the half of the evidence no artifact carries. A slot grant
// says the scheduler admitted the item; only the child's own arrival at the
// provider says the execution happened, and a store row read without it would
// be asking about work that may never have run.
func childEntryRow(before Observation) row {
fleet := firstProgress(before, probeFleetChildEntered)
lone := firstProgress(before, probeChildEntered)
return row{
Semantic: "the children that reached the provider", Authority: "the scripted provider, in-process",
Artifact: "none observed", Reconstruction: "in-process only",
Before: fmt.Sprintf("fan-out=%s lone=%s", fleet, lone),
After: "—",
Verdict: enteredVerdict(fleet == "true" && lone == "true"),
}
}
func enteredVerdict(both bool) string {
if both {
return verdictHolds
}
return verdictNotMeasured
}
func firstProgress(o Observation, key string) string {
if vals := o.Progress[key]; len(vals) > 0 {
return vals[0]
}
return "unrecorded"
}
// settledStoreRow is the control that makes the rest readable. It is the same
// entry point, the same fan-out and the same store, differing only in that this
// item finished — so if it too were absent, the finding would be about
// persistence in general rather than about executions in flight.
func settledStoreRow(before, after Observation) row {
ids := executionsIn(before, popSettled)
return row{
Semantic: "a settled fan-out item, in the store", Authority: storeAuthority,
Artifact: "subagents/<ref>.meta.json", Reconstruction: "ListSubagentsByParent, joined by execution id",
Before: storeReading(before, ids),
After: storeReading(after, ids),
Verdict: controlVerdict(acknowledged(before, ids) && acknowledged(after, ids), len(ids) > 0),
}
}
// executingStoreRow is the arm. The conjunction is stated in the row rather
// than left to the reader: the journal proves the slot was granted, the child
// proves the execution happened, and this column is what the store can say
// about it. All three, or the row is not the finding it looks like.
func executingStoreRow(before, after Observation) row {
ids := executionsIn(before, popRunning)
entered := firstProgress(before, probeFleetChildEntered) == "true"
return row{
Semantic: "an executing fan-out item, in the store", Authority: storeAuthority,
Artifact: "subagents/<ref>.meta.json",
Reconstruction: "STARTED in the journal, entered at the provider, and asked of the store",
Before: storeReading(before, ids),
After: storeReading(after, ids),
Verdict: executingVerdict(before, after, ids, entered),
}
}
// executingVerdict refuses the two readings that would overstate the row. An
// execution nothing proves entered is not a silent store, and a population the
// death never produced is not evidence of anything.
func executingVerdict(before, after Observation, ids []string, entered bool) string {
switch {
case len(ids) == 0 || !entered:
return verdictNotMeasured
case acknowledged(before, ids) || acknowledged(after, ids):
return verdictHolds
default:
return verdictViolated
}
}
// loneStoreRow is the other entry point, in the same death and the same store.
// It is what makes "selectively blind" a statement about the store rather than
// a comparison between two probe runs.
func loneStoreRow(before, after Observation) row {
ids := executionsIn(before, popLone)
return row{
Semantic: "an executing lone delegation, in the store", Authority: storeAuthority,
Artifact: "subagents/<ref>.meta.json", Reconstruction: "the same read, one entry point over",
Before: storeReading(before, ids),
After: storeReading(after, ids),
Verdict: controlVerdict(acknowledged(before, ids) && acknowledged(after, ids), len(ids) > 0),
}
}
// refusedStoreRow is the negative control, and it bounds any fix this arm might
// support. An item the scheduler never admitted produced nothing, so the store
// is owed no record for it — and a change that recorded every opening would
// satisfy the row above by reintroducing exactly that.
func refusedStoreRow(before, after Observation) row {
ids := executionsIn(before, popRefused)
silent := !acknowledged(before, ids) && !acknowledged(after, ids)
return row{
Semantic: "a refused fan-out item, in the store", Authority: crossAuthority,
Artifact: "subagents/<ref>.meta.json, <stem>.execution.jsonl",
Reconstruction: "queued, never started: the store must stay silent",
Before: storeReading(before, ids),
After: storeReading(after, ids),
Verdict: controlVerdict(silent, len(ids) > 0),
}
}
func controlVerdict(ok bool, measured bool) string {
switch {
case !measured:
return verdictNotMeasured
case ok:
return verdictHolds
default:
return verdictViolated
}
}
// listingRow is the surface a person or a model reaches for. It reads through
// the workspace filter and the lineage walk the tool applies, which the store
// read above does not — so it is checked against that read first: a listing
// that cannot even see the settled item is a filter answering, not a store.
func listingRow(before, after Observation) row {
settled := executionsIn(before, popSettled)
return row{
Semantic: "what list_subagents would show", Authority: "SubagentStore.ListForParent",
Artifact: "subagents/<ref>.meta.json",
Reconstruction: "the read behind the tool, filtered by workspace and lineage",
Before: listingReading(before),
After: listingReading(after),
Verdict: listingVerdict(before, after, settled),
}
}
func listingReading(o Observation) string {
if o.Listing.Err != "" {
return "error: " + o.Listing.Err
}
out := make([]string, 0, len(o.Listing.Rows))
for _, r := range o.Listing.Rows {
out = append(out, orDash(r.From)+"="+r.Status)
}
sort.Strings(out)
return orNone(join(out))
}
// listingVerdict answers only once the listing has been shown to work. Its
// filters can drop every row for reasons that have nothing to do with this arm,
// and an empty listing read as an invisible execution is the substitution the
// probe exists to refuse.
func listingVerdict(before, after Observation, settled []string) string {
if len(settled) == 0 || !listingHas(after, settled) {
return verdictNotMeasured
}
if listingHas(before, executionsIn(before, popRunning)) {
return verdictHolds
}
return verdictViolated
}
func listingHas(o Observation, ids []string) bool {
for _, id := range ids {
found := false
for _, r := range o.Listing.Rows {
if r.From == id {
found = true
break
}
}
if !found {
return false
}
}
return len(ids) > 0
}
// rebuildUnmovedRow pins what a fix must not move. A store that learned to
// record executions in flight would leave stale records behind for a restart to
// fold, and the one outcome this whole line of work exists to prevent is a
// rebuild that answers with a node the dead process was running.
func rebuildUnmovedRow(before, after Observation) row {
rebuilt := rebuiltGraph(after)
var ghosts []string
for _, n := range rebuilt.Graph.Nodes {
if n.State == agentgraph.StateRunning {
ghosts = append(ghosts, n.ID)
}
}
var cut []string
for _, i := range rebuilt.Interrupted {
if ofActiveFleet(i.Execution) || ofLoneTask(i.Execution) {
cut = append(cut, i.Execution+":"+interruptionKind(i))
}
}
sort.Strings(ghosts)
sort.Strings(cut)
return row{
Semantic: "no ghost, and how the rebuild names the cut", Authority: rebuildAuthority,
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
Reconstruction: "execgraph.Rebuild over both, after the death",
Before: fmt.Sprintf("%d executing at death", len(executionsIn(before, popRunning))+len(executionsIn(before, popLone))),
After: fmt.Sprintf("ghosts=%s cut=%s", orNone(join(ghosts)), orNone(join(cut))),
Verdict: controlVerdict(len(ghosts) == 0 && len(cut) > 0, len(before.Executions) > 0),
}
}
// activeStoreInvalid reports the premise this arm could not establish. All four
// populations in one death is the whole design: without them the rows compare
// different runs, and a row that compares nothing must not be read as green.
func activeStoreInvalid(before Observation) string {
for _, pop := range measured() {
if len(executionsIn(before, pop)) == 0 {
return "the death held nothing in population " + string(pop)
}
}
if firstProgress(before, probeFleetChildEntered) != "true" {
return "no fan-out child reached the provider, so no execution is proven to have happened"
}
return ""
}
// runActiveStoreConstruct drives one delegation and one fan-out under a ceiling
// they share, and dies with all four populations standing. The delegation goes
// first and hangs: it is what keeps the ceiling from emptying under the fan-out,
// so the item refused admission stays refused rather than starting a moment
// later and taking the arm's negative control with it.
func runActiveStoreConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
prompt := fanOutTurn(turn+1, taskSentinel+" "+fleetActiveSentinel)
go func() { _ = ctrl.Run(ctx, prompt) }()
if err := waitForPopulations(ctrl, prov); err != nil {
return err
}
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
// Whether each child reached the provider is known only here, and it is half
// of what makes a silent store a finding rather than a missing record for
// work that never ran.
obs.Progress[probeFleetChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(childHang)))}
obs.Progress[probeChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(childHold)))}
if err := writeObservation(root, obs); err != nil {
return err
}
os.Exit(0)
return nil
}
// waitForPopulations blocks until the journal holds all four states, then holds
// still and asks again. A refusal is a state nothing announces, so the second
// reading is what separates an item the scheduler is holding back from one that
// was merely slow to be admitted.
func waitForPopulations(ctrl *control.Controller, prov *scripted) error {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
if populationsStanding(ctrl, prov) {
time.Sleep(2 * time.Second)
if !populationsStanding(ctrl, prov) {
return errUnexpected("a refusal that was still standing",
journalStates(control.ExecutionHistory(ctrl.SessionPath())))
}
return nil
}
time.Sleep(50 * time.Millisecond)
}
return errUnexpected("one death holding all four populations",
journalStates(control.ExecutionHistory(ctrl.SessionPath())))
}
func populationsStanding(ctrl *control.Controller, prov *scripted) bool {
held := map[population]bool{}
for _, e := range control.ExecutionHistory(ctrl.SessionPath()) {
held[populationOf(e)] = true
}
return held[popSettled] && held[popRunning] && held[popRefused] && held[popLone] &&
prov.fleets.held(childEntered(childHang)) && prov.fleets.held(childEntered(childHold))
}
func journalStates(entries []execjournal.Entry) string {
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, fmt.Sprintf("%s:started=%t,queued=%t,open=%t", e.ID, e.Started(), e.Queued(), e.Open()))
}
sort.Strings(out)
return orNone(join(out))
}
-345
View File
@@ -1,345 +0,0 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"reasonix/internal/state/sessionstore"
"strings"
"reasonix/internal/contract/config"
"reasonix/internal/contract/provider"
"reasonix/internal/state/memory"
)
// arm is one death-and-resume run. Lever runs between the two processes and is
// what separates the three compaction questions: nothing changed, only the
// stable prefix changed, the covered conversation changed.
type arm struct {
name string
asks string
lever func(armRoot, Observation) error
}
// appendsAfterFold names the arms whose construct phase adds one turn after
// the fold. Every arm carrying a lever needs it: without it the projection is
// already gone at the boundary for an unrelated reason, and the lever's own
// effect cannot be read out of the result.
func appendsAfterFold(name string) bool {
return name != "exact" && name != armRefoldIntoBody && !graphArm(name) && !uiArm(name) &&
!schedulerWaitArm(name) && !terminalArm(name) && !deriveArm(name) &&
!loneTaskArm(name) && !cancelRoutingArm(name) && !lineageArm(name) && !activeStoreArm(name) &&
!skillArm(name) && !ephemeralArm(name) && !hostExecArm(name)
}
// armAppendAfterFold separates the two ways a projection can fail validation
// after a restart: a covered prefix that no longer matches, and a projection
// that covers the whole transcript, whose only cross-process validation is a
// transcript version the reload does not carry.
const armAppendAfterFold = "append-after-fold"
// armRefoldIntoBody folds twice. The second fold reads the provider-visible
// view — stored body plus live tail — and must record a boundary in canonical
// terms, which the body has no counterpart for.
const armRefoldIntoBody = "refold-into-body"
// The tail arms separate two ways the live tail can move. tail-edit and
// tail-truncate change the transcript past the fold, where the covered hash
// cannot notice; tail-rewind takes the path a person actually drives, which
// throws the projection away without asking whether coverage still holds.
const (
armTailEdit = "tail-edit"
armTailTruncate = "tail-truncate"
armTailRewind = "tail-rewind"
// armCoveredRewind is the negative control the rewind path will need before
// its invalidation can become conditional: rewinding below the fold removes
// history the digest folded, and no coverage check may excuse that.
armCoveredRewind = "covered-rewind"
// armTodoIdentity moves host step identity while a fold is between the
// model and it: identity A, fold, live tail, identity B, fold again.
armTodoIdentity = "todo-identity"
// armOpenDecision dies while a question is open. It asks what the host
// believes afterwards, not whether one particular card comes back.
armOpenDecision = "open-decision"
// The three successors to an interrupted barrier. Each starts where
// open-decision ends and differs only in what the next process does, which
// is the whole question: when does a recorded interruption stop being the
// context every later request carries?
armIdleRestart = "interrupted-idle"
armUnrelatedTurn = "interrupted-unrelated"
armDirectAnswer = "interrupted-answered"
)
// successorTurn is what the middle process says, empty when it only restarts.
func successorTurn(arm string) (string, bool) {
switch arm {
case armIdleRestart:
return "", true
case armUnrelatedTurn:
return marker(120) + " List the files you would look at first.", true
case armDirectAnswer:
return marker(121) + " Yes, go ahead with that — take the side below the fold.", true
}
return "", false
}
func arms() []arm {
return []arm{
{name: "exact", asks: "nothing changed between the processes"},
{name: armAppendAfterFold, asks: "nothing changed, and one turn was appended after the fold"},
{name: armRefoldIntoBody, asks: "a second fold reaches into the body the first one stored"},
{name: "system-swap", asks: "only the stable prefix changed, against the surviving baseline", lever: swapSystemPrefix},
{name: armTailEdit, asks: "a message past the fold changed", lever: editTailRow},
{name: armTailTruncate, asks: "the messages past the fold were dropped", lever: truncateTail},
{name: armTailRewind, asks: "the same truncation, driven through the rewind a person uses"},
{name: armCoveredRewind, asks: "a rewind that lands below the fold boundary"},
{name: armTodoIdentity, asks: "host step identity moved across two folds and a live tail"},
{name: armOpenDecision, asks: "the process died while a decision was open"},
{name: armIdleRestart, asks: "an interrupted barrier, and nobody does anything"},
{name: armUnrelatedTurn, asks: "an interrupted barrier, then a turn about something else"},
{name: armDirectAnswer, asks: "an interrupted barrier, then a turn that reads like an answer to it"},
{name: armGraphCompleted, asks: "a fan-out whose items had all settled when the process died"},
{name: armGraphRunning, asks: "a fan-out with a child still executing when the process died"},
{name: armGraphMixed, asks: "a fan-out holding completed, failed, adopted and running at once"},
{name: armUIGraphMixed, asks: "which door each fact reaches the frontend through, after a death"},
{name: armTaskCompleted, asks: "a lone delegation that finished, in a turn that closed"},
{name: armTaskRunning, asks: "a lone delegation still executing when the process died"},
{name: armTaskFgQueued, asks: "what a lone delegation is drawn as while the scheduler holds it back"},
{name: armTaskBgQueued, asks: "a backgrounded delegation the ceiling refused, with its job already handed over"},
{name: armTaskBgRunning, asks: "a backgrounded delegation executing inside its job"},
{name: armCancelTool, asks: "whether a stop reaches an ordinary tool the turn is waiting on"},
{name: armCancelBackground, asks: "whether a stop leaves work already handed to a job alone"},
{name: armCancelHeadlessOwner, asks: "who owns the cancellation of a turn the controller never admitted"},
{name: armLineageForeground, asks: "control: does a stop reach the tree the turn owns?"},
{name: armLineageBackground, asks: "does it also reach the tree it handed away?"},
{name: armLineageJobKill, asks: "control: does a job's own stop reach its tree?"},
{name: armTaskFgQueuedCancel, asks: "a delegation stopped while the scheduler still held it back"},
{name: armTaskBgQueuedCancel, asks: "the same, backgrounded: a job killed before it was ever admitted"},
{name: armTaskFgRunningCancel, asks: "a delegation stopped while its child was executing"},
{name: armTaskBgRunningCancel, asks: "the same, backgrounded: a job killed while its child ran"},
{name: armWaitSlots, asks: "an item the session's total ceiling refused"},
{name: armWaitWriters, asks: "an item the writer ceiling refused, with total capacity free"},
{name: armWaitClaim, asks: "an item a path conflict refused, with both ceilings free"},
{name: armWaitTransition, asks: "what a reported wait cause does when the thing it named stops holding the item"},
{name: armTerminalAdopted, asks: "an item that never ran and still holds an answer"},
{name: armTerminalSkippedDep, asks: "an item that never ran and holds none, because its dependency failed"},
{name: armTerminalCancelled, asks: "an item a cancellation ended before it was admitted"},
{name: armTerminalContext, asks: "whether a delivered answer's edge follows from what is already durable"},
{name: armChildTerminal, asks: "whether the store keeps every terminal the graph draws"},
{name: armDeriveSkipBoth, asks: "a skip whose two upstreams both ended without an answer"},
{name: armDeriveSkipFlip, asks: "the same, with the two failures in the other order"},
{name: armIdentitySemantics, asks: "what a node's model and effort report, across two producers and the store"},
{name: armDeriveAnswered, asks: "a dependent whose upstreams answered, one by completing and one by adopting"},
{name: armFleetActiveStore, asks: "whether the store can be asked about a fan-out item that is executing"},
{name: armSkillQueued, asks: "whether a subagent skill asks the same scheduler as every other delegation"},
{name: armSkillRunning, asks: "what names a subagent skill while its child is executing"},
{name: armSkillCompleted, asks: "what a finished subagent skill leaves, through both entry points"},
{name: armSkillCancel, asks: "what a stop reaches when a subagent skill is running"},
{name: armReadOnlySkillQueued, asks: "whether the read-only skill entry point asks the same scheduler"},
{name: armReadOnlySkillRunning, asks: "what names a read-only skill while its child is executing"},
{name: armReadOnlySkillCompleted, asks: "what a finished read-only skill leaves behind, if anything"},
{name: armEphemeralTask, asks: "can the execution authority account for what read_only_task draws?"},
{name: armEphemeralSkill, asks: "the same of read_only_skill, the other ephemeral entry point"},
{name: armHostRunning, asks: "what a slash-started subagent leaves when the process dies mid-execution"},
{name: armHostQueued, asks: "what names slash-started work the scheduler is holding back"},
{name: armHostCompleted, asks: "what a finished slash invocation leaves besides its answer"},
{name: armHostCancel, asks: "what a stop reaches, and what history it leaves"},
{name: armHeadlessCompleted, asks: "does the headless host surface leave the same provenance?"},
{name: armHeadlessRunningCancel, asks: "the same, stopped through the context its caller holds"},
{name: armHeadlessQueuedCancel, asks: "the same while the scheduler is still holding it back"},
{name: "covered-mutation", asks: "the covered conversation changed, against the surviving baseline", lever: mutateCoveredRow},
}
}
// swapSystemPrefix saves a memory fact into this arm's home. The lever is a
// real user action rather than a poked field: the saved-fact index folds into
// the boot-time system prompt, so the next process composes a different one.
func swapSystemPrefix(root armRoot, _ Observation) error {
store := memory.StoreFor(config.RootsForHome(root.Home).MemoryUserDir(), root.Workspace)
_, err := store.Save(memory.Memory{
Name: "probe-prefix-lever",
Title: "Probe prefix lever",
Description: "A fact saved between the two processes so the composed system prompt differs.",
Type: memory.TypeProject,
Scope: memory.FactScopeProject,
// Pinned, not relevant: only a pinned body rides the stable prefix. The
// saved-fact index is retrieval-only and would move nothing.
Activation: memory.ActivationPinned,
Body: "The runtime-resume probe wrote this to move the stable prefix.",
})
return err
}
// mutateCoveredRow rewrites one provider-visible message inside the prefix the
// projection folded, through the same rewrite save a rewind or a recovery
// branch uses. This is the control arm: the digest's material changed, so the
// projection must not be reused.
func mutateCoveredRow(_ armRoot, before Observation) error {
session, err := sessionstore.LoadSession(before.SessionPath)
if err != nil {
return err
}
msgs := session.Snapshot()
covered := min(before.Sidecar.CoveredCount, len(msgs))
target := -1
for i := range covered {
if msgs[i].Role == provider.RoleUser || msgs[i].Role == provider.RoleAssistant {
target = i
break
}
}
if target < 0 {
return errUnexpected("a covered conversation row to mutate", covered)
}
msgs[target].Content += "\n[probe: covered row mutated]"
session.Replace(msgs)
return session.SaveRewrite(before.SessionPath)
}
// editTailRow rewrites one message the projection does not claim to cover. The
// covered hash cannot see it, so the projection should keep serving.
func editTailRow(_ armRoot, before Observation) error {
return rewriteSession(before, func(msgs []provider.Message, covered int) ([]provider.Message, error) {
for i := covered; i < len(msgs); i++ {
if msgs[i].Role == provider.RoleUser || msgs[i].Role == provider.RoleAssistant {
msgs[i].Content += "\n[probe: tail row edited]"
return msgs, nil
}
}
return nil, errUnexpected("a live tail row to edit", covered)
})
}
// truncateTail drops everything past the fold boundary, leaving the covered
// prefix exactly as the projection folded it.
func truncateTail(_ armRoot, before Observation) error {
return rewriteSession(before, func(msgs []provider.Message, covered int) ([]provider.Message, error) {
return msgs[:covered], nil
})
}
// rewriteSession applies a transcript rewrite through the save a rewind or a
// recovery branch uses, refusing an arm with no live tail to act on.
func rewriteSession(before Observation, edit func([]provider.Message, int) ([]provider.Message, error)) error {
session, err := sessionstore.LoadSession(before.SessionPath)
if err != nil {
return err
}
msgs := session.Snapshot()
covered := before.Sidecar.CoveredCount
if covered <= 0 || covered >= len(msgs) {
return errUnexpected("a live tail past the fold", covered)
}
next, err := edit(msgs, covered)
if err != nil {
return err
}
session.Replace(next)
return session.SaveRewrite(before.SessionPath)
}
func orchestrate(work, only, jsonOut string, keep bool) error {
self, err := os.Executable()
if err != nil {
return err
}
if work == "" {
work, err = os.MkdirTemp("", "runtime-resume-")
if err != nil {
return err
}
if !keep {
defer os.RemoveAll(work)
}
}
var results []armResult
for _, a := range arms() {
if only != "" && a.name != only {
continue
}
result, err := runArm(self, work, a)
if err != nil {
return fmt.Errorf("arm %s: %w", a.name, err)
}
results = append(results, result)
}
if len(results) == 0 {
return fmt.Errorf("no arm matched %q", only)
}
fmt.Print(renderMatrix(results, work))
return writeJSON(jsonOut, results)
}
func runArm(self, work string, a arm) (armResult, error) {
root := rootFor(filepath.Join(work, a.name))
if err := root.requireFresh(); err != nil {
return armResult{}, err
}
if err := root.create(); err != nil {
return armResult{}, err
}
if err := spawn(self, "construct", root, a.name); err != nil {
return armResult{}, fmt.Errorf("construct process: %w", err)
}
before, err := readObservation(root, "construct")
if err != nil {
return armResult{}, err
}
if a.lever != nil {
if err := a.lever(root, before); err != nil {
return armResult{}, fmt.Errorf("lever: %w", err)
}
}
if _, ok := successorTurn(a.name); ok {
if err := spawn(self, "successor", root, a.name); err != nil {
return armResult{}, fmt.Errorf("successor process: %w", err)
}
}
if err := spawn(self, "resume", root, a.name); err != nil {
return armResult{}, fmt.Errorf("resume process: %w", err)
}
after, err := readObservation(root, "resume")
if err != nil {
return armResult{}, err
}
var extra *Observation
if _, ok := successorTurn(a.name); ok {
obs, err := readObservation(root, "successor")
if err != nil {
return armResult{}, err
}
extra = &obs
} else if phase := extraPhase(a.name); phase != "" {
obs, err := readObservation(root, phase)
if err != nil {
return armResult{}, err
}
extra = &obs
}
return classify(a, extra, before, after), nil
}
// spawn runs one phase as a real child process and waits for it to exit. The
// OS process is the measurement boundary: a nil-ed object can still be revived
// by a package-level singleton, a live sink, or a cache, and none of those
// survive here.
func spawn(self, phase string, root armRoot, armName string) error {
cmd := exec.Command(self, "-phase="+phase, "-root="+root.Dir, "-arm="+armName)
cmd.Env = childEnv(root)
cmd.Dir = root.Workspace
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
return cmd.Run()
}
func writeJSON(path string, results []armResult) error {
if strings.TrimSpace(path) == "" {
return nil
}
b, err := marshalResults(results)
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}
-230
View File
@@ -1,230 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"reasonix/internal/contract/provider"
"reasonix/internal/session/control"
)
// Where a stop goes — and, first, whose stop it is. A controller owns the turns
// it admits itself and cancels those; a synchronous Run belongs to the caller
// that passed the context. Measuring the second with the first says nothing
// about either.
const (
// armCancelTool is the control that says where a defect lives. If an
// ordinary tool's context is cancelled and a delegation's is not, the fault
// is in how a delegation derives its context; if neither is, it is upstream
// of both.
armCancelTool = "cancel-ordinary-tool"
// armCancelBackground is the negative control, and the one that decides what
// a fix may do: work already handed to a job has an owner of its own, and a
// cancel that reached into it would take back the handoff.
armCancelBackground = "cancel-background-handoff"
// armCancelHeadlessOwner reads the other surface. A synchronous Run is never
// admitted through the gate, so the controller holds no cancel for it and
// the caller's own context holds the only one. Both halves are asserted: a
// controller that started claiming this would be taking ownership from the
// only holder that has it.
armCancelHeadlessOwner = "cancel-headless-owner"
)
func cancelRoutingArm(name string) bool {
switch name {
case armCancelTool, armCancelBackground, armCancelHeadlessOwner:
return true
}
return false
}
// guardedArm names the arms driven through a turn the controller admits itself,
// which is the surface a person's Stop reaches.
func guardedArm(name string) bool {
return cancelTaskArm(name) || name == armCancelTool || name == armCancelBackground ||
name == armSkillCancel || name == armHostCancel
}
const (
sleepSentinel = "PROBE-SLEEP"
sleepCallID = "probe_sleep"
// sleepSeconds outlasts the settling this probe waits through, so a shell
// that ends promptly ended because something ended it.
sleepSeconds = 30
)
// sleepCall is an ordinary foreground tool that does nothing but wait. It is
// the shell every turn can call, which is the point: it derives its context the
// way every other tool does.
func sleepCall() []provider.Chunk {
args, _ := json.Marshal(map[string]any{
"command": fmt.Sprintf("sleep %d", sleepSeconds),
"description": "waits until something stops it",
})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: sleepCallID, Name: "bash", Arguments: string(args)},
}}
}
// runCancelRoutingConstruct drives the arm's work, stops it the way that
// surface's owner stops it, and records what the stop reached. Nothing waits
// for the work to end: whether it ends at all is the measurement.
func runCancelRoutingConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
sentinel := sleepSentinel
if arm == armCancelBackground {
sentinel = taskSentinel
}
prompt := fanOutTurn(turn+1, sentinel)
ctx, cancelCaller := context.WithCancel(ctx)
defer cancelCaller()
if guardedArm(arm) {
ctrl.Send(prompt)
} else {
go func() { _ = ctrl.Run(ctx, prompt) }()
}
if err := waitForCancelSubject(sink, prov, arm); err != nil {
return err
}
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
obs.Progress[probeStopReached] = stopReadings(ctrl, sink, prov, arm, cancelCaller)
if err := writeObservation(root, obs); err != nil {
return err
}
os.Exit(0)
return nil
}
// probeStopReached carries what each stop reached into the observation. The key
// names the probe so no row reads it as a status the kernel emitted.
const probeStopReached = "probe:stop-reached"
// stopReadings issues this surface's stop and reports what it reached. The
// headless arm issues both in order — the controller's, which owns nothing
// there, and then the caller's, which owns everything.
func stopReadings(ctrl *control.Controller, sink *graphSink, prov *scripted, arm string, cancelCaller context.CancelFunc) []string {
// What the stop found before it was issued. Work already ended would make
// every answer below meaningless, and this probe has made that mistake once.
before := "live"
if !workLive(sink, prov, arm) {
before = "already gone"
}
ctrl.Cancel()
first := "controller-cancel before=" + before + " " + reachedAfter(ctrl, sink, prov, arm)
if arm != armCancelHeadlessOwner {
return []string{first}
}
cancelCaller()
return []string{first, "caller-cancel " + reachedAfter(ctrl, sink, prov, arm)}
}
// reachedAfter waits out the settling this probe allows and reports what is
// still live. The wait stays well below the shell's own sleep, so work that has
// ended was ended by the stop rather than by finishing.
func reachedAfter(ctrl *control.Controller, sink *graphSink, prov *scripted, arm string) string {
stopped := time.Now()
deadline := stopped.Add(8 * time.Second)
for time.Now().Before(deadline) && workLive(sink, prov, arm) {
time.Sleep(100 * time.Millisecond)
}
live := "gone"
if workLive(sink, prov, arm) {
live = "still running"
}
return fmt.Sprintf("turn=%s work=%s after=%s",
turnStanding(ctrl, arm), live, time.Since(stopped).Round(time.Second))
}
func workLive(sink *graphSink, prov *scripted, arm string) bool {
if arm == armCancelBackground {
return prov.fleets.held(childEntered(childHang)) && !prov.fleets.held(childLeft(childHang))
}
_, ended := sink.toolResult(sleepCallID)
return !ended
}
// turnStanding reports the gate only where the gate is the authority. A
// synchronous Run is never admitted through it, so reading it there would say
// "ended" about a turn it was never told about — which is how this probe first
// mistook an ownership boundary for a cancellation defect.
func turnStanding(ctrl *control.Controller, arm string) string {
if !guardedArm(arm) {
return "not admitted (caller-owned)"
}
if ctrl.Running() {
return "still active"
}
return "ended"
}
// waitForCancelSubject blocks until the work this arm is about to stop is
// really under way: a stop that arrived before it would measure nothing.
func waitForCancelSubject(sink *graphSink, prov *scripted, arm string) error {
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
if arm == armCancelBackground {
if prov.fleets.held(childEntered(childHang)) {
return nil
}
} else if _, seen := sink.toolDispatched(sleepCallID); seen {
// The shell starts with the dispatch, not with the event; a moment's
// grace keeps the stop from racing its own subject.
time.Sleep(500 * time.Millisecond)
return nil
}
time.Sleep(50 * time.Millisecond)
}
return errUnexpected("work under way to stop", "nothing started")
}
// cancelRoutingRows read the arm's own readings. The three arms want different
// answers to the same question, which is why they run together: a fix that
// cancels everything a session knows about passes one and fails two.
func cancelRoutingRows(arm string, before Observation) []row {
readings := before.Progress[probeStopReached]
first, second := reading(readings, 0), reading(readings, 1)
switch arm {
case armCancelHeadlessOwner:
return []row{
ownershipRow("a controller stop on a turn it never admitted", first,
"leaves the caller's work alone", stillRunning(first)),
ownershipRow("the caller's own stop", second,
"ends the work it owns", !stillRunning(second)),
}
case armCancelBackground:
return []row{
ownershipRow("the turn after a stop", first, "a cancelled turn ends",
strings.Contains(first, "turn=ended")),
ownershipRow("work already handed to a job", first,
"a stop aimed at the turn leaves work the turn no longer owns", stillRunning(first)),
}
}
return []row{
ownershipRow("the turn after a stop", first, "a cancelled turn ends",
strings.Contains(first, "turn=ended")),
ownershipRow("the tool the turn was waiting on", first,
"a stop reaches the work its turn is waiting on", !stillRunning(first)),
}
}
func ownershipRow(semantic, got, want string, ok bool) row {
return row{
Semantic: semantic, Authority: "the surface that owns this cancellation",
Artifact: "none: in-process state", Reconstruction: want,
Before: want, After: orNone(got), Verdict: held(ok),
}
}
func reading(readings []string, at int) string {
if at < len(readings) {
return readings[at]
}
return ""
}
func stillRunning(reading string) bool { return strings.Contains(reading, "work=still running") }
-156
View File
@@ -1,156 +0,0 @@
package main
import (
"fmt"
"strings"
)
// How a host can stand after dying with a question open. The names are the
// outcomes, not a pass and its failures: two of them are designs a host may
// legitimately choose, and only measurement says which one this is.
const (
outcomeResumable = "resumable" // the question is still there to answer
outcomeInterrupted = "interrupted-explicit" // it is gone, and the host says a turn was cut
outcomeLostSilent = "LOST-SILENT" // it is gone and nothing records that it existed
)
// decisionRows classify what a restart inherited. The safety row comes first
// because it is the only one with a single acceptable answer: whether the
// decision survives is a design question, whether the effect it blocked stayed
// blocked is not.
func decisionRows(before, after Observation) []row {
return []row{
deferredRow(before, after),
outcomeRow(before, after),
barrierRow(before, after),
modelContextRow(before, after),
obligationRow(before, after),
}
}
// deferredRow asserts the write the question held back never ran — not while
// the answer was pending, and not because a later process loaded the session.
func deferredRow(before, after Observation) row {
verdict := verdictHolds
if before.Deferred.Executed || after.Deferred.Executed {
verdict = verdictViolated
}
return row{
Semantic: "the effect the decision held back", Authority: "the workspace",
Artifact: before.Deferred.MarkerPath, Reconstruction: "the blocked write never runs, before or after the boundary",
Before: ranOrNot(before), After: ranOrNot(after), Verdict: verdict,
}
}
func ranOrNot(o Observation) string {
if o.Deferred.Executed {
return "RAN"
}
return "did not run"
}
// outcomeRow names which of the three standings the restart produced. It is
// stated, not judged: a host that drops the question and records the
// interruption is a defensible design, and one that drops both is not.
func outcomeRow(before, after Observation) row {
verdict := verdictHolds
var outcome string
switch {
case len(after.Decisions) > 0:
outcome = outcomeResumable
case len(after.Interrupted) > 0:
outcome = outcomeInterrupted
case after.Obligation.InterruptionMarked > 0 || len(after.Obligation.UnansweredCalls) > 0:
outcome = outcomeInterrupted
default:
outcome = outcomeLostSilent
verdict = verdictViolated
}
return row{
Semantic: "what the restart inherited", Authority: "approvalManager and the transcript",
Artifact: "none for the question; the transcript for the obligation",
Reconstruction: "resumable, interrupted-explicit, or nothing at all",
Before: decisionSummary(before), After: outcome, Verdict: verdict,
}
}
func decisionSummary(o Observation) string {
if len(o.Decisions) == 0 {
return "no decision open"
}
var kinds []string
for _, d := range o.Decisions {
kinds = append(kinds, string(d.Kind))
}
return strings.Join(kinds, ", ") + " open"
}
// barrierRow is the evidence behind an interrupted classification: a barrier
// the host recorded before anyone could be asked, still open, with no owner in
// this process waiting on it. It is deliberately not offered as answerable.
func barrierRow(before, after Observation) row {
return row{
Semantic: "durable adjudication barriers", Authority: "the adjudication log",
Artifact: "<stem>.adjudication.jsonl", Reconstruction: "open records with no live owner in this process",
Before: barrierSummary(before), After: barrierSummary(after), Verdict: verdictStable,
}
}
func barrierSummary(o Observation) string {
if len(o.Interrupted) == 0 {
return "none"
}
var out []string
for _, b := range o.Interrupted {
out = append(out, b.Kind+":"+b.ID)
}
return join(out) + " (not answerable)"
}
// modelContextRow is the other read surface: what the next request tells the
// model. One block when an interruption stands, none when it does not, and it
// must never arrive as something answerable.
func modelContextRow(before, after Observation) row {
verdict := verdictHolds
if before.ModelSeesInterruption != expectedBlocks(before) || after.ModelSeesInterruption != expectedBlocks(after) {
verdict = verdictViolated
}
return row{
Semantic: "what the next request tells the model", Authority: "control, projected per request",
Artifact: "none: derived, never stored", Reconstruction: "one block while an interruption stands, none otherwise",
Before: modelContextSummary(before), After: modelContextSummary(after), Verdict: verdict,
}
}
func expectedBlocks(o Observation) int {
if len(o.Interrupted) > 0 {
return 1
}
return 0
}
func modelContextSummary(o Observation) string {
return fmt.Sprintf("%d block(s) for %d interruption(s)", o.ModelSeesInterruption, len(o.Interrupted))
}
// obligationRow shows the evidence behind that classification, so a reader can
// see what the host still says rather than trusting the label.
func obligationRow(before, after Observation) row {
return row{
Semantic: "what the transcript still owes", Authority: "the canonical transcript",
Artifact: "<stem>.jsonl", Reconstruction: "tool calls with no result, and interruption records",
Before: obligationSummary(before), After: obligationSummary(after), Verdict: verdictStable,
}
}
func obligationSummary(o Observation) string {
return fmt.Sprintf("unanswered %s, interruption records %d",
orNone(join(o.Obligation.UnansweredCalls)), o.Obligation.InterruptionMarked)
}
func orNone(s string) string {
if s == "" {
return "none"
}
return s
}
-354
View File
@@ -1,354 +0,0 @@
package main
import (
"context"
"os"
"reasonix/internal/state/sessionstore"
"sort"
"strings"
"time"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/session/control"
"reasonix/internal/state/execgraph"
)
// The derivability arms. Everything before them asked what survives; these ask
// what still has to be written down. A fact a restart can compute from what is
// already durable needs no record of its own, and adding one would give it a
// second owner for nothing.
const (
// armDeriveSkipBoth cuts a branch with two upstreams that both end without
// an answer. Which of them the graph names is the question: the durable
// facts show two, and the picture shows one.
armDeriveSkipBoth = "derive-skip-both"
// armDeriveSkipFlip is the same graph with the two failures in the other
// order. If the named cause moves while the durable facts do not, the cause
// is history rather than a consequence of the end state.
armDeriveSkipFlip = "derive-skip-flip"
// armDeriveAnswered is the negative control and the context sample at once:
// two upstreams that both answer, one by completing and one by adopting, so
// the dependent must run and both deliveries must be accounted for.
armDeriveAnswered = "derive-answered"
)
func deriveArm(name string) bool {
switch name {
case armDeriveSkipBoth, armDeriveSkipFlip, armDeriveAnswered:
return true
}
return false
}
func deriveSentinels(arm string) string {
if arm == armDeriveAnswered {
return fleetWarmSentinel + " " + fleetDeriveSentinel
}
return fleetDeriveSentinel
}
// runDeriveConstruct lets the fan-out settle and dies before the turn ends, the
// way every terminal arm does: a skip is published only in the closing delta.
func runDeriveConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
runCtx, cancelRun := context.WithCancel(ctx)
defer cancelRun()
go func() { _ = ctrl.Run(runCtx, fanOutTurn(turn+1, deriveSentinels(arm))) }()
if err := waitForDerive(sink, arm, prov); err != nil {
return err
}
if err := writeObservation(root, capture("construct", arm, bootSystem, ctrl, sink, root)); err != nil {
return err
}
os.Exit(0)
return nil
}
// waitForDerive holds until the arm's shape is on the graph. The skip arms
// release their second failure once the first has been recorded, so which
// upstream the fan-out names is decided rather than raced.
func waitForDerive(sink *graphSink, arm string, prov *scripted) error {
if arm == armDeriveAnswered {
// Waiting on a count would be satisfied by the warm-up alone. The
// delivery edge is what the arm compares, so it waits for that.
return waitForShape(sink, func(g agentgraph.Graph) bool {
return len(contextTargets(g)) > 0 && len(nodesInState(g, agentgraph.StateAdopted)) > 0
}, "an adopted upstream and a delivered answer")
}
// The first failure decides which upstream is named — the fan-out cuts the
// branch when it processes that result. Only then is the second released,
// so the order is constructed rather than raced.
if err := waitForShape(sink, func(g agentgraph.Graph) bool {
return len(nodesInState(g, agentgraph.StateFailed)) > 0
}, "the first upstream to fail"); err != nil {
return err
}
prov.releaseOnce()
// A skip is published only in the closing delta, so the group has to end
// before the state the arm compares exists at all.
return waitForShape(sink, func(g agentgraph.Graph) bool {
return len(nodesInState(g, agentgraph.StateSkipped)) > 0 &&
len(nodesInState(g, agentgraph.StateFailed)) >= 2
}, "both upstreams failed and the dependent skipped")
}
// contextTargets are the items the graph shows receiving an upstream answer.
func contextTargets(g agentgraph.Graph) []string {
var out []string
for _, e := range g.Edges {
if e.Kind == agentgraph.Context {
out = append(out, e.To)
}
}
return out
}
func waitForShape(sink *graphSink, ok func(agentgraph.Graph) bool, want string) error {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
graph, _ := sink.snapshot()
if ok(graph) {
return nil
}
time.Sleep(50 * time.Millisecond)
}
graph, _ := sink.snapshot()
return errUnexpected(want, waitStates(graph))
}
// answeredFromDurable is Answered() computed the way a restart would have to:
// an adoption says so on its own opening, and everything else is the terminal
// the sub-agent store kept, joined by the execution id the child records.
func answeredFromDurable(o Observation) map[string]bool {
terminal := map[string]string{}
for _, f := range o.Children.Facts {
if f.ParentToolCallID != "" {
terminal[f.ParentToolCallID] = f.Status
}
}
out := map[string]bool{}
for _, e := range o.Executions {
out[e.ID] = e.Disposition == "adopted" || terminal[e.ID] == string(sessionstore.SubagentCompleted)
}
return out
}
// impliedSkipped is the state a restart would derive: an item the orchestration
// released without ever starting it, ordered behind something that did not
// answer. Nothing here reads a disposition, because that is what is missing.
func impliedSkipped(o Observation) string {
answered := answeredFromDurable(o)
var out []string
for _, e := range o.Executions {
if e.Started() || e.Queued() || e.SettledAt.IsZero() || len(e.DependsOn) == 0 {
continue
}
for _, up := range e.DependsOn {
if !answered[up] {
out = append(out, e.ID)
break
}
}
}
sort.Strings(out)
return join(out)
}
// impliedSkipCauses are every upstream that could have been named, and the two
// rules that would pick one: declaration order, and the earliest release. A
// cause is derivable only if some rule always agrees with the picture.
func impliedSkipCauses(o Observation) (all, byOrder, bySettle string) {
answered := answeredFromDurable(o)
byID := map[string]int{}
for i, e := range o.Executions {
byID[e.ID] = i
}
var every, first, earliest []string
for _, e := range o.Executions {
if e.Started() || e.Queued() || e.SettledAt.IsZero() || len(e.DependsOn) == 0 {
continue
}
var unanswered []string
for _, up := range e.DependsOn {
if !answered[up] {
unanswered = append(unanswered, up)
}
}
if len(unanswered) == 0 {
continue
}
every = append(every, e.ID+"<-{"+strings.Join(unanswered, ",")+"}")
first = append(first, e.ID+"<-"+unanswered[0])
pick := unanswered[0]
for _, up := range unanswered[1:] {
if i, j := byID[up], byID[pick]; o.Executions[i].SettledAt.Before(o.Executions[j].SettledAt) {
pick = up
}
}
earliest = append(earliest, e.ID+"<-"+pick)
}
sort.Strings(every)
sort.Strings(first)
sort.Strings(earliest)
return join(every), join(first), join(earliest)
}
// actualSkipCause is the upstream the fan-out actually named, read off the node
// the graph settled. It is the picture's own answer, not a derivation.
func actualSkipCause(o Observation) string {
var out []string
for _, n := range o.Graph.Nodes {
if n.State == agentgraph.StateSkipped && n.Err != "" {
out = append(out, n.ID+": "+n.Err)
}
}
sort.Strings(out)
return orNone(join(out))
}
// impliedContextFromDurable predicts the delivery edges from durable facts
// alone: a started item receives an answer from every upstream that answered.
func impliedContextFromDurable(o Observation) string {
answered := answeredFromDurable(o)
var out []string
for _, e := range o.Executions {
if !e.Started() {
continue
}
for _, up := range e.DependsOn {
if answered[up] {
out = append(out, e.ID+"<-"+up)
}
}
}
sort.Strings(out)
return join(out)
}
// deriveRows are the three questions, kept apart. A state that derives says
// nothing about whether the cause does, and one arm's agreement says nothing
// about a rule that has only ever seen one sample.
func deriveRows(arm string, before, after Observation) []row {
rows := []row{
joinRow(before, after),
skipStateRow(before, after),
contextDeriveRow(before, after),
}
if arm != armDeriveAnswered {
rows = append(rows, skipCauseRow(before, after))
}
return rows
}
// joinRow is what every derivation below stands on: the child records name the
// execution they ran for, so a restart can ask the store what each item ended
// as. Without it none of the other rows could be computed at all.
func joinRow(before, after Observation) row {
return valueRow("executions the store can be asked about", "child metadata's parent tool-call id",
"subagents/<ref>.meta.json", "joined to the journal by execution id",
joinedExecutions(before), joinedExecutions(after), true)
}
func joinedExecutions(o Observation) string {
var out []string
for _, f := range o.Children.Facts {
if f.ParentToolCallID != "" {
out = append(out, f.ParentToolCallID+"="+f.Status)
}
}
sort.Strings(out)
return join(out)
}
// skipStateRow compares the state a restart would derive against the one the
// picture drew. Agreement means StateSkipped needs no record of its own.
func skipStateRow(before, after Observation) row {
actual := join(nodesInState(graphOf(before), agentgraph.StateSkipped))
implied := impliedSkipped(after)
verdict := verdictHolds
if implied != actual {
verdict = verdictViolated
}
return row{
Semantic: "skipped state: derived against drawn", Authority: "the journal joined to the store",
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
Reconstruction: "settled, never started, ordered behind something that did not answer",
Before: "drawn " + orNone(actual), After: "derived " + orNone(implied), Verdict: verdict,
}
}
// skipCauseRow keeps the harder question separate, and reads it three ways: the
// upstream the picture named, the two rules this file works out on its own, and
// what the production fold concludes. The independence matters — a probe that
// only asked the implementation would agree with it by construction.
func skipCauseRow(before, after Observation) row {
all, byOrder, bySettle := impliedSkipCauses(after)
return row{
Semantic: "skipped cause: what the picture named, what the facts permit",
Authority: "the fan-out's own choice against the durable upstream set",
Artifact: "none: the reason text is not recorded",
Reconstruction: "declaration order, or earliest released, over the upstreams that did not answer",
Before: actualSkipCause(before),
After: "candidates " + orNone(all) + " | by-order " + orNone(byOrder) +
" | by-settle " + orNone(bySettle) + " | rebuild " + orNone(rebuiltSkipCauses(after)),
Verdict: verdictStable,
}
}
// rebuiltSkipCauses is what the production fold names, asked the same question
// through its own code path.
func rebuiltSkipCauses(o Observation) string {
children := make([]execgraph.ChildOutcome, 0, len(o.Children.Facts))
for _, f := range o.Children.Facts {
children = append(children, execgraph.ChildOutcome{
Execution: f.ParentToolCallID, Ref: f.Ref, Status: f.Status,
})
}
rebuilt := execgraph.Rebuild(o.Executions, children, nil)
var out []string
for _, n := range rebuilt.Graph.Nodes {
if n.State != agentgraph.StateSkipped {
continue
}
if cause := execgraph.SkipCause(o.Executions, children, n.ID); cause != "" {
out = append(out, n.ID+"<-"+cause)
}
}
sort.Strings(out)
return join(out)
}
// contextDeriveRow asks the same question of the delivery edge, now that a
// restart can tell which upstreams answered.
func contextDeriveRow(before, after Observation) row {
actual, implied := contextEdges(before), impliedContextFromDurable(after)
verdict := verdictHolds
if implied != actual {
verdict = verdictViolated
}
return row{
Semantic: "context edges: derived against drawn", Authority: "the journal joined to the store",
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
Reconstruction: "a started item receives from every upstream that answered",
Before: "drawn " + orNone(actual), After: "derived " + orNone(implied), Verdict: verdict,
}
}
// deriveArmInvalid refuses an arm that did not reach the shape it compares.
func deriveArmInvalid(arm string, before Observation) string {
if arm == armDeriveAnswered {
if len(nodesInState(graphOf(before), agentgraph.StateAdopted)) == 0 {
return "no item was adopted, so the arm never had an answered upstream that did not run"
}
if contextEdges(before) == "" {
return "no answer was delivered, so there is no context edge to derive"
}
return ""
}
if len(nodesInState(graphOf(before), agentgraph.StateSkipped)) == 0 {
return "no dependent was skipped; the fan-out ended as " + orNone(terminalSummary(before))
}
if len(nodesInState(graphOf(before), agentgraph.StateFailed)) < 2 {
return "only one upstream ended without an answer, so the cause was never ambiguous"
}
return ""
}
-239
View File
@@ -1,239 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"time"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/contract/provider"
"reasonix/internal/session/control"
)
// The two ephemeral entry points, read against the execution read model's own
// authority: the snapshot is what a reader starts from and what a gap restores,
// and the stream is a projection of the same facts. A node only the stream has
// is therefore not a lighter node but one the authority cannot account for, and
// these arms ask whether either entry point produces one.
const (
armEphemeralTask = "ephemeral-task-authority"
armEphemeralSkill = "ephemeral-skill-authority"
)
func ephemeralArm(name string) bool {
return name == armEphemeralTask || name == armEphemeralSkill
}
const (
ephemeralSentinel = "PROBE-EPHEMERAL"
// ephemeralCallID is the call the model makes. Both entry points use it, so
// the two arms differ by which tool ran and by nothing else.
ephemeralCallID = "probe_ephemeral"
)
// ephemeralCall dispatches the entry point this arm measures. Its child hangs,
// because every row here is about a run that is still happening: an ephemeral
// execution that has ended owes no reader anything.
func ephemeralCall(arm string) []provider.Chunk {
name, args := "read_only_task", map[string]any{
"prompt": childHang + " ephemeral research", "description": "still executing at death",
}
if arm == armEphemeralSkill {
name, args = "read_only_skill", map[string]any{
"name": probeSkillName, "arguments": childHang + " ephemeral research",
}
}
raw, _ := json.Marshal(args)
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: ephemeralCallID, Name: name, Arguments: string(raw)},
}}
}
// runEphemeralConstruct drives one ephemeral run and reads three surfaces at the
// same instant, while its child is inside the provider: what the stream drew,
// what the authority holds, and what a reader is left with once a gap makes it
// start from the authority again.
func runEphemeralConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
go func() { _ = ctrl.Run(ctx, fanOutTurn(turn+1, ephemeralSentinel)) }()
if err := waitForEphemeral(prov); err != nil {
return err
}
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
obs.Progress[probeChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(childHang)))}
// One instant, three readings. Taken here because two of them cannot be
// taken later at all: the authority is recomputed per call, and what a gap
// leaves is a claim about the moment the gap happens.
live, _ := sink.snapshot()
authority := ctrl.ExecutionGraph()
obs.Progress[probeEphemeralLive] = []string{ephemeralNodes(live)}
obs.Progress[probeEphemeralAuthority] = []string{ephemeralNodes(authority.Graph)}
obs.Progress[probeEphemeralAfterGap] = []string{ephemeralNodes(afterGap(authority.Graph))}
if err := writeObservation(root, obs); err != nil {
return err
}
os.Exit(0)
return nil
}
// The three readings one instant produces. They ride the observation because
// none of them survives the process, and the arm's whole question is what a
// reader could see while the run was happening.
const (
probeEphemeralLive = "probe:ephemeral-live"
probeEphemeralAuthority = "probe:ephemeral-authority"
probeEphemeralAfterGap = "probe:ephemeral-after-gap"
)
// afterGap is what a view holds once a dropped stream makes it start from the
// authority again: the snapshot, and only the snapshot. A resync replaces the
// folded state rather than merging into it — that is what makes the snapshot the
// authority — so whatever the fold held and it does not has left the screen.
func afterGap(authority agentgraph.Graph) agentgraph.Graph { return authority }
func waitForEphemeral(prov *scripted) error {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
if prov.fleets.held(childEntered(childHang)) {
// Let the picture settle: a reading taken the instant the child
// arrives could miss a delta that was already on its way.
time.Sleep(time.Second)
return nil
}
time.Sleep(50 * time.Millisecond)
}
return errUnexpected("an ephemeral child inside the provider", "it never reached one")
}
// ephemeralNodes renders the nodes belonging to this arm's run, worker and group
// alike: a group card drawn for work the authority cannot account for is the
// same defect as a worker one.
func ephemeralNodes(g agentgraph.Graph) string {
var out []string
for _, n := range g.Nodes {
if strings.HasPrefix(n.ID, ephemeralCallID) {
out = append(out, n.ID+":"+orDash(string(n.State)))
}
}
sort.Strings(out)
return orNone(join(out))
}
func ephemeralRows(arm string, before, after Observation) []row {
return []row{
ephemeralLiveRow(before),
ephemeralAuthorityRow(before),
ephemeralGapRow(before),
ephemeralRestartRow(before, after),
ephemeralDurableRow(before, after),
ephemeralContractRow(arm, before),
}
}
const authorityName = "Controller.ExecutionGraph (the snapshot a reader starts from)"
func ephemeralLiveRow(before Observation) row {
drawn := firstProgress(before, probeEphemeralLive)
return row{
Semantic: "what the stream drew while it ran", Authority: liveAuthority,
Artifact: "none observed", Reconstruction: "in-process only",
Before: drawn, After: "—", Verdict: presenceVerdict(drawn != "none"),
}
}
func ephemeralAuthorityRow(before Observation) row {
held := firstProgress(before, probeEphemeralAuthority)
return row{
Semantic: "what the authority held at the same instant", Authority: authorityName,
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
Reconstruction: "execgraph.Rebuild over durable facts, recomputed on demand",
Before: held, After: "—", Verdict: presenceVerdict(held != "none"),
}
}
// ephemeralGapRow is the one that decides. A reader that loses the stream starts
// from the authority again, so whatever the fold held and the authority does not
// leaves the screen — while the child it named is still holding a slot.
func ephemeralGapRow(before Observation) row {
left := firstProgress(before, probeEphemeralAfterGap)
return row{
Semantic: "what a reader is left with after a gap", Authority: authorityName,
Artifact: "none: the fold is replaced, never merged",
Reconstruction: "a resync starts from the authority",
Before: left, After: "—", Verdict: presenceVerdict(left != "none"),
}
}
func ephemeralRestartRow(before, after Observation) row {
return row{
Semantic: "what a restart can say about it", Authority: rebuildAuthority,
Artifact: "<stem>.execution.jsonl",
Reconstruction: "execgraph.Rebuild in the next process",
Before: "executing inside the provider",
After: ephemeralNodes(rebuiltGraph(after).Graph),
Verdict: presenceVerdict(ephemeralNodes(rebuiltGraph(after).Graph) != "none"),
}
}
// ephemeralDurableRow is the contract both entry points already promise. It is
// read rather than assumed, because a convergence that made one of them visible
// by recording it would satisfy the rows above and break this one.
func ephemeralDurableRow(before, after Observation) row {
return presenceRow("journal and store", crossAuthority,
"<stem>.execution.jsonl, subagents/<ref>.meta.json",
"what an ephemeral entry point promises to leave: nothing",
ephemeralDurable(before), ephemeralDurable(after))
}
func ephemeralDurable(o Observation) []string {
var out []string
for _, e := range o.Executions {
if strings.HasPrefix(e.ID, ephemeralCallID) {
out = append(out, "journal:"+e.ID)
}
}
for _, f := range o.Children.Facts {
if strings.HasPrefix(f.ParentToolCallID, ephemeralCallID) {
out = append(out, "store:"+f.ParentToolCallID+"="+f.Status)
}
}
sort.Strings(out)
return out
}
// ephemeralContractRow states the authority contract as a conjunction. A node
// the stream drew, an authority that cannot account for it, and a child still
// executing: that is not a lighter kind of node, it is one that leaves the
// screen at the next gap while the work it named goes on holding a slot.
func ephemeralContractRow(arm string, before Observation) row {
live := firstProgress(before, probeEphemeralLive) != "none"
authority := firstProgress(before, probeEphemeralAuthority) != "none"
running := firstProgress(before, probeChildEntered) == "true"
answer, verdict := "no node was drawn: nothing claims what the authority cannot hold", verdictHolds
switch {
case !running:
answer, verdict = "the child never reached the provider", verdictNotMeasured
case live && !authority:
answer, verdict = "drawn by the stream, absent from the authority, and still executing", verdictViolated
case live && authority:
answer, verdict = "drawn, and the authority accounts for it", verdictHolds
}
return row{
Semantic: "a node the authority cannot account for", Authority: crossAuthority,
Artifact: "none, which is the point",
Reconstruction: "the stream and the snapshot, read at one instant",
Before: answer, After: "—", Verdict: verdict,
}
}
// ephemeralArmInvalid reports the premise this arm could not establish.
func ephemeralArmInvalid(before Observation) string {
if firstProgress(before, probeChildEntered) != "true" {
return "the ephemeral child never reached the provider, so nothing was executing at the reading"
}
return ""
}
-641
View File
@@ -1,641 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"regexp"
"slices"
"strings"
"time"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/contract/provider"
"reasonix/internal/session/control"
)
// The three fan-out arms. Each dies at a different point in one fleet's life,
// because "what survives" has no single answer: an item that finished, one
// still executing, and one the graph knows about without ever running are
// carried by different host state, and only separating them says which.
const (
// armGraphCompleted lets the fleet finish and the turn close before the
// process exits. Nothing is interrupted, so a loss here is not about
// interruption at all — it says execution provenance itself is ephemeral.
armGraphCompleted = "graph-completed"
// armGraphRunning kills the process with a child mid-execution. It asks the
// one question a completed fleet cannot: what does the new process believe
// about work whose owner is gone?
armGraphRunning = "graph-running"
// armGraphMixed carries every state at once — completed, running, adopted,
// failed, skipped — plus a read grant, a write grant, and a dependency edge.
// It is the density arm: one death, every semantic the graph claims to hold.
armGraphMixed = "graph-mixed"
)
func graphArm(name string) bool {
return name == armGraphCompleted || name == armGraphRunning || name == armGraphMixed
}
// Child sentinels. A child's prompt decides what its run does, and the probe
// reads the prompt off the request's last user message rather than scanning the
// whole conversation: the parent's own transcript quotes every fleet argument,
// so a scan would make the parent answer as its own children.
const (
childDone = "PROBE-CHILD-DONE"
childHang = "PROBE-CHILD-HANG"
childFail = "PROBE-CHILD-FAIL"
)
// Parent sentinels, one per fleet the arm dispatches.
const (
fleetWarmSentinel = "PROBE-FLEET-WARM"
fleetMixedSentinel = "PROBE-FLEET-MIXED"
fleetPairSentinel = "PROBE-FLEET-PAIR"
// The scheduler arms. Holder fills a ceiling and never ends; refused asks
// for admission the scheduler must deny. Two sentinels rather than one
// because the claim and transition arms need the holder to be already
// admitted before the second fleet is dispatched.
fleetHolderSentinel = "PROBE-FLEET-HOLDER"
fleetRefusedSentinel = "PROBE-FLEET-REFUSED"
// The terminal arms. Their fan-outs run to completion, because a skip is
// published only in the closing delta; the turn is then held open so the
// process still dies mid-turn.
fleetTerminalSentinel = "PROBE-FLEET-TERMINAL"
// Named so it is not contained in PROBE-PARALLEL-IDENTITY. Sentinels are
// matched by substring, so one inside another dispatches a fan-out its arm
// never asked for — this pair silently ran cancelParallel in the identity
// arm, whose rows then read a scenario nobody wrote.
parallelSentinel = "PROBE-PARALLEL-CANCEL"
fleetOutcomesSentinel = "PROBE-FLEET-OUTCOMES"
fleetDeriveSentinel = "PROBE-FLEET-DERIVE"
fleetIdentitySentinel = "PROBE-FLEET-IDENTITY"
parallelIdentity = "PROBE-PARALLEL-IDENTITY"
// The active-store arm. Its fan-out shares one ceiling with a lone
// delegation, so the four populations it needs are reached in one death.
fleetActiveSentinel = "PROBE-FLEET-ACTIVE"
)
// childHold is a child that reports holding its slot before it blocks, so a
// later fleet can be dispatched knowing the ceiling is already occupied. Timing
// it any other way races the scheduler.
const (
childHold = "PROBE-CHILD-HOLD"
// childRelease finishes only when the arm frees it, which is how capacity
// is returned while a refusal is still standing.
childRelease = "PROBE-CHILD-RELEASE"
// childFailLate fails only once the arm frees it, so two failures in one
// fan-out have a decided order instead of a raced one. Its wording shares no
// prefix with childFail: the sentinels are matched by substring, so a name
// containing another's makes the shorter one win and the delay disappear.
childFailLate = "PROBE-CHILD-DELAYEDFAIL"
)
// probeClaimPath is the write path the claim arm overlaps. The refused writer
// asks for a path inside it, which is a conflict no ceiling change releases.
const (
probeClaimPath = "probe-claim"
probeClaimInner = "probe-claim/inner"
)
// subagentRefPattern finds the references an earlier fleet's aggregate reported.
// The mixed arm adopts one of them, which is the only way to reach StateAdopted
// through the tool's own contract rather than by constructing the state.
var subagentRefPattern = regexp.MustCompile(`sa_[A-Za-z0-9_-]+`)
// childScript answers one delegated run. Hanging is expressed as a context read
// so the child holds its scheduler slot until the process dies — a sleep long
// enough to look the same would still be a race against the probe's deadline.
func (s *scripted) childScript(ctx context.Context, sentinel string) []provider.Chunk {
// Reaching here is the child's own proof that it started, and returning is
// its proof that something let it go: an arm asking whether a cancellation
// reached a running child has no other way to know.
s.fleets.first(childEntered(sentinel))
defer s.fleets.first(childLeft(sentinel))
switch sentinel {
case childHold:
// Reaching a provider call means the slot is already held, which is the
// only moment a later fleet can be dispatched against a full ceiling
// without racing it.
s.holding.Do(func() { close(s.held) })
<-ctx.Done()
return nil
case childRelease:
<-s.release
return append(text("Child released."), done())
case childFailLate:
<-s.release
return []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf("probe child failed on release")}}
case childHang:
<-ctx.Done()
// The context closed, and this is the only place that can say so: a
// caller that stopped waiting leaves the same absence behind.
s.fleets.first(childCtxDone(sentinel))
return nil
case childFail:
return []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf("probe child failed on purpose")}}
default:
return append(text("Child answered."), done())
}
}
// askedInPrompt reports whether this request's own user turns carry a sentinel.
// The user side only: a fleet's arguments live in an assistant message, so a
// whole-conversation scan makes a parent answer as one of its own children.
// Every user turn, not the last: the host appends its own notes after the
// prompt, and the last user message in a request is frequently one of those.
func askedInPrompt(req provider.Request, sentinel string) bool {
for _, m := range req.Messages {
if m.Role == provider.RoleUser && strings.Contains(m.Content, sentinel) {
return true
}
}
return false
}
// childSentinel reports which child script this request is, empty when the
// request is a parent turn.
func childSentinel(req provider.Request) string {
for _, sentinel := range []string{childDone, childHang, childFail, childHold, childRelease, childFailLate} {
if askedInPrompt(req, sentinel) {
return sentinel
}
}
return ""
}
// fanOutTurnRecorded reports whether the dispatching turn is in these messages.
// The probe reads its own sentinel rather than counting rows: a turn appended
// at some later point would still be there, and only the prompt says whether
// this is the turn the fan-out was opened in.
func fanOutTurnRecorded(msgs []provider.Message) bool {
for _, m := range msgs {
if strings.Contains(m.Content, fleetPairSentinel) || strings.Contains(m.Content, fleetMixedSentinel) {
return true
}
}
return false
}
// fleetCall renders one fleet dispatch. Nothing closes the round it opens: all
// three arms die inside the turn, so the host's final-answer readiness check
// never runs and the arms measure a process boundary rather than a task list.
func fleetCall(id string, tasks []map[string]any) []provider.Chunk {
args, _ := json.Marshal(map[string]any{"tasks": tasks})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: id, Name: "fleet", Arguments: string(args)},
}}
}
// warmFleet finishes two children so a later fleet has a completed reference to
// adopt. Both are read-only: nothing about the warm-up is under measurement.
func warmFleet() []provider.Chunk {
return fleetCall("probe_fleet_warm", []map[string]any{
{"id": "w1", "prompt": childDone + " warm one", "description": "warm one", "read_only": true},
{"id": "w2", "prompt": childDone + " warm two", "description": "warm two", "read_only": true},
})
}
// pairFleet is the two-item shape both single-state arms use. The running arm
// dies with the second item still executing; the completed arm lets both
// finish, so the two differ by when the process exits and nothing else.
func pairFleet(second string) []provider.Chunk {
return fleetCall("probe_fleet_pair", []map[string]any{
{"id": "p1", "prompt": childDone + " pair one", "description": "pair one", "read_only": true},
{"id": "p2", "prompt": second + " pair two", "description": "pair two", "read_only": true},
})
}
// mixedFleet is the density arm's dispatch: one item per terminal the graph can
// record, plus one still running, and the two grants told apart. The write item
// declares a path so preflight admits it beside the readers; it never writes,
// because the grant is the envelope under measurement, not the effect.
func mixedFleet(adoptRef string) []provider.Chunk {
return fleetCall("probe_fleet_mixed", []map[string]any{
// Model and effort are named so the identity row measures something: two
// nodes agreeing that a field is empty proves nothing about recovering it.
{"id": "m1", "prompt": childDone + " mixed one", "description": "completes", "read_only": true,
"model": probeModelRef, "effort": "high"},
{"id": "m2", "prompt": childFail + " mixed two", "description": "fails", "read_only": true},
{"id": "m3", "prompt": childDone + " mixed three", "description": "skipped by m2", "read_only": true, "depends_on": []string{"m2"}},
{"id": "m4", "adopt_ref": adoptRef, "description": "adopted"},
{"id": "m5", "prompt": childHang + " mixed five", "description": "running at death", "write_paths": []string{"probe-fanout-out"}},
})
}
// opens reports whether this round is the one that dispatches that fleet.
func (s *scripted) opens(req provider.Request, sentinel string) bool {
return askedInPrompt(req, sentinel) && s.fleets.first(sentinel)
}
// opensRun is the same for the fan-out an arm is built around: the terminal and
// derive arms hold the turn open once theirs has gone out.
func (s *scripted) opensRun(req provider.Request, sentinel string) bool {
if !s.opens(req, sentinel) {
return false
}
s.opened.Store(true)
return true
}
// fanOut answers a parent turn that opens a fleet, at most once per sentinel.
// The sentinel is read off the current user turn, and the latch is what stops
// the round that receives the aggregate from dispatching the same fleet again.
func (s *scripted) fanOut(req provider.Request) ([]provider.Chunk, bool) {
switch {
case s.opens(req, fleetWarmSentinel):
return append(warmFleet(), done()), true
case s.opens(req, fleetPairSentinel):
if s.arm == armWaitSlots {
return append(slotsFleet(), done()), true
}
second := childDone
if s.arm == armGraphRunning {
second = childHang
}
return append(pairFleet(second), done()), true
case s.opens(req, fleetSettledSentinel):
return append(settledFleet(), done()), true
case s.opens(req, fleetLiveSentinel):
return append(liveFleet(), done()), true
case s.opens(req, fleetMixedSentinel):
if s.arm == armTerminalAdopted {
return append(adoptOnlyFleet(adoptableRef(req)), done()), true
}
if s.arm == armUIGraphMixed {
return append(uiMixedFleet(adoptableRef(req)), done()), true
}
return append(mixedFleet(adoptableRef(req)), done()), true
case s.opensRun(req, fleetIdentitySentinel):
return append(identityFleet(adoptableRef(req)), done()), true
case s.opens(req, parallelIdentity):
return append(identityParallel(), done()), true
case s.opensRun(req, fleetDeriveSentinel):
return append(deriveFleet(s.arm, adoptableRef(req)), done()), true
case s.opensRun(req, fleetOutcomesSentinel):
return append(outcomesFleet(), done()), true
case s.opensRun(req, fleetTerminalSentinel):
return append(terminalFleet(s.arm), done()), true
case s.opensRun(req, parallelSentinel):
return append(cancelParallel(), done()), true
case s.fleets.held(taskSentinel) && s.opens(req, fleetActiveSentinel):
// The delegation goes first and its own child says when the slot is
// taken. Dispatching before that lets the fan-out take the whole
// ceiling, and the arm loses the entry point it exists to compare.
<-s.held
return append(activeStoreFleet(), done()), true
case s.opens(req, fleetHolderSentinel):
return append(holderFleet(s.arm), done()), true
case s.opens(req, fleetRefusedSentinel):
// The holder's own child says when its ceiling is occupied. Dispatching
// before that lets the refused item win the race and start.
<-s.held
return append(refusedFleet(s.arm), done()), true
}
return nil, false
}
// holderFleet occupies the ceiling this arm measures. The slots arm needs no
// holder — its refused item is in the same fleet as the runs that fill the
// ceiling — so only the arms that need one dispatch it in the background.
func holderFleet(arm string) []provider.Chunk {
tasks := []map[string]any{
{"id": "h1", "prompt": childHold + " holder", "description": "holds the ceiling", "write_paths": []string{probeClaimPath}},
{"id": "h2", "prompt": childHang + " filler", "description": "filler", "read_only": true},
}
if arm == armWaitTransition {
// The second run is what the arm later releases: freeing total capacity
// while the writer ceiling stays full is how the blocker changes.
tasks[1] = map[string]any{"id": "h2", "prompt": childRelease + " releasable", "description": "released mid-wait", "read_only": true}
}
if bothHoldersReport(arm) {
tasks[1] = map[string]any{"id": "h2", "prompt": childHold + " second holder", "description": "holds the ceiling", "read_only": true}
}
return backgroundFleetCall("probe_fleet_holder", tasks)
}
// bothHoldersReport names the arms that leave one slot free. Either holder may
// win it, so both report holding, and the refused delegation's own child is then
// the only arrival that means anything — a filler that hung under the same
// sentinel would be read as the subject having started.
func bothHoldersReport(arm string) bool {
return queuedTaskArm(arm) || queuedSkillArm(arm) || queuedHostArm(arm)
}
// refusedFleet asks for admission the scheduler must deny, with every check
// above this arm's cause already cleared.
func refusedFleet(arm string) []provider.Chunk {
refused := map[string]any{"id": "r1", "prompt": childHang + " refused", "description": "refused admission"}
switch arm {
case armWaitWriters:
// Disjoint from the holder's path, so only the writer ceiling can
// refuse it.
refused["write_paths"] = []string{"probe-writers-own"}
case armWaitClaim:
refused["write_paths"] = []string{probeClaimInner}
default:
refused["write_paths"] = []string{"probe-transition-own"}
}
return fleetCall("probe_fleet_refused", []map[string]any{
refused,
{"id": "r2", "prompt": childHang + " sibling", "description": "sibling", "read_only": true},
})
}
// terminalFleet runs to completion holding the disposition its arm measures.
// The skipped arm cuts a branch with a failure; the context arm delivers a real
// answer across an ordering edge, which is the only way a context edge appears.
func terminalFleet(arm string) []provider.Chunk {
upstream := map[string]any{"id": "up", "prompt": childDone + " upstream", "description": "upstream", "read_only": true}
if arm == armTerminalSkippedDep {
upstream["prompt"] = childFail + " upstream"
}
return fleetCall("probe_fleet_terminal", []map[string]any{
upstream,
{"id": "down", "prompt": childDone + " downstream", "description": "downstream", "read_only": true, "depends_on": []string{"up"}},
})
}
// cancelParallel opens more items than the session admits at once, so the arm
// can interrupt the group while one has started and another has not. That split
// is what parallel_tasks reports as cancelled against skipped.
func cancelParallel() []provider.Chunk {
args, _ := json.Marshal(map[string]any{"tasks": []map[string]any{
{"prompt": childHang + " first", "description": "started"},
{"prompt": childHang + " second", "description": "never started"},
{"prompt": childHang + " third", "description": "never started"},
}})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: "probe_parallel", Name: "parallel_tasks", Arguments: string(args)},
}}
}
// adoptOnlyFleet runs to completion with one item adopted and one that ran.
// Nothing hangs: the arm compares two settled items whose only difference is
// that one executed, which a fan-out still in flight could not show.
func adoptOnlyFleet(adoptRef string) []provider.Chunk {
return fleetCall("probe_fleet_terminal", []map[string]any{
{"id": "a1", "adopt_ref": adoptRef, "description": "adopted"},
{"id": "a2", "prompt": childDone + " ran", "description": "ran", "read_only": true},
})
}
// outcomesFleet reaches all three executed terminals in one group: one item
// completes, one fails, and two are still running when the arm interrupts. The
// store's record of each is then compared under identical conditions.
func outcomesFleet() []provider.Chunk {
return fleetCall("probe_fleet_outcomes", []map[string]any{
{"id": "o1", "prompt": childDone + " completes", "description": "completes", "read_only": true},
{"id": "o2", "prompt": childFail + " fails", "description": "fails", "read_only": true},
{"id": "o3", "prompt": childHang + " cancelled", "description": "cancelled", "read_only": true},
{"id": "o4", "prompt": childHang + " cancelled too", "description": "cancelled too", "read_only": true},
})
}
// identityFleet names a worker identity four ways: not at all, by model, by
// effort, and not at all because nothing runs. What the graph then shows for
// each is what says which layer of the resolution it is reporting.
func identityFleet(adoptRef string) []provider.Chunk {
return fleetCall("probe_fleet_identity", []map[string]any{
{"id": "i1", "prompt": childDone + " default", "description": "inherits", "read_only": true},
{"id": "i2", "prompt": childDone + " override", "description": "names a model", "read_only": true,
"model": probeAltModelRef, "effort": "high"},
{"id": "i3", "prompt": childDone + " effort only", "description": "names an effort", "read_only": true,
"effort": "low"},
{"id": "i4", "adopt_ref": adoptRef, "description": "runs nothing"},
})
}
// identityParallel asks the same of the other producer, which resolves through
// a shorter chain: it has no profile layer to consult.
func identityParallel() []provider.Chunk {
args, _ := json.Marshal(map[string]any{"tasks": []map[string]any{
{"prompt": childDone + " p default", "description": "inherits"},
{"prompt": childDone + " p override", "description": "names a model",
"model": probeAltModelRef, "effort": "high"},
}})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: "probe_parallel_identity", Name: "parallel_tasks", Arguments: string(args)},
}}
}
// deriveFleet orders one dependent behind two upstreams. The skip arms end both
// upstreams without an answer, one immediately and one on release, so which of
// them the fan-out names is decided by the order rather than by a race; the
// answered arm gives the dependent one completed and one adopted upstream, so
// it must run and both deliveries must be accounted for.
func deriveFleet(arm, adoptRef string) []provider.Chunk {
first, second := childFail+" first", childFailLate+" second"
if arm == armDeriveSkipFlip {
first, second = childFailLate+" first", childFail+" second"
}
up := []map[string]any{
{"id": "a", "prompt": first, "description": "upstream a", "read_only": true},
{"id": "b", "prompt": second, "description": "upstream b", "read_only": true},
}
if arm == armDeriveAnswered {
up = []map[string]any{
{"id": "a", "prompt": childDone + " first", "description": "upstream a", "read_only": true},
{"id": "b", "adopt_ref": adoptRef, "description": "upstream b"},
}
}
return fleetCall("probe_fleet_derive", append(up, map[string]any{
"id": "c", "prompt": childDone + " dependent", "description": "dependent",
"read_only": true, "depends_on": []string{"a", "b"},
}))
}
// activeStoreFleet reaches three fan-out populations under one ceiling. The
// first item settles and frees the slot the other two then contend for, so one
// executes and one is refused — which of them is the scheduler's to decide, and
// the arm reads the populations off the journal rather than naming them here.
func activeStoreFleet() []provider.Chunk {
return fleetCall(activeFleetCallID, []map[string]any{
{"id": "a1", "prompt": childDone + " settles", "description": "settles", "read_only": true},
{"id": "a2", "prompt": childHang + " holds", "description": "contends for the freed slot",
"read_only": true, "depends_on": []string{"a1"}},
{"id": "a3", "prompt": childHang + " contends", "description": "contends for the freed slot",
"read_only": true, "depends_on": []string{"a1"}},
})
}
// slotsFleet fills the total ceiling and asks for one more, all read-only: a
// reader clears every writer check by not being a writer.
func slotsFleet() []provider.Chunk {
return fleetCall("probe_fleet_refused", []map[string]any{
{"id": "s1", "prompt": childHang + " one", "description": "fills a slot", "read_only": true},
{"id": "s2", "prompt": childHang + " two", "description": "fills a slot", "read_only": true},
{"id": "s3", "prompt": childHang + " three", "description": "refused admission", "read_only": true},
})
}
// backgroundFleetCall dispatches a fleet that returns a job id at once. The
// holder has to keep holding while a later fleet is dispatched, which a
// foreground call cannot do: it does not return until every item has settled.
func backgroundFleetCall(id string, tasks []map[string]any) []provider.Chunk {
args, _ := json.Marshal(map[string]any{"tasks": tasks, "run_in_background": true})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: id, Name: "fleet", Arguments: string(args)},
}}
}
// adoptableRef picks a reference the warm-up fleet reported. It reads the
// conversation the model reads: the aggregate names each child's reference, and
// an arm that cannot find one has not established its premise.
func adoptableRef(req provider.Request) string {
for _, m := range slices.Backward(req.Messages) {
if found := subagentRefPattern.FindAllString(m.Content, -1); len(found) > 0 {
return found[len(found)-1]
}
}
return ""
}
// fanOutTurn is the prompt that opens one fleet.
func fanOutTurn(n int, sentinel string) string {
return fmt.Sprintf("%s %s: dispatch the fan-out.", marker(n), sentinel)
}
// fanOutSentinels is what this arm's one prompt carries. The mixed arm names
// both of its fleets in the same turn: the warm-up has to finish before a
// reference can be adopted, and a fleet's aggregate returns into the same turn
// that dispatched it, so the second round dispatches the mixed fleet without
// the turn ever closing.
func fanOutSentinels(arm string) string {
if arm == armUIGraphMixed {
return fleetWarmSentinel + " " + fleetSettledSentinel + " " + fleetMixedSentinel
}
if arm == armGraphMixed {
return fleetWarmSentinel + " " + fleetMixedSentinel
}
return fleetPairSentinel
}
// runFanOutConstruct drives the arm's fan-out and exits while the turn is still
// open. Every arm dies mid-turn, including the completed one: what separates
// them is which states the graph holds at that moment, and a clean shutdown
// would cancel a running child and settle its node — the one thing a process
// that dies mid-fan-out does not do.
func runFanOutConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, turn int) error {
go func() { _ = ctrl.Run(ctx, fanOutTurn(turn+1, fanOutSentinels(arm))) }()
if err := waitForFanOut(sink, arm); err != nil {
return err
}
if err := writeObservation(root, capture("construct", arm, bootSystem, ctrl, sink, root)); err != nil {
return err
}
os.Exit(0)
return nil
}
// waitForFanOut blocks until the graph shows the shape this arm was built to
// die with. Waiting on the shape rather than on a duration is what keeps the
// arm honest: a timer that fired early would report a smaller fan-out as if
// that were what the restart inherited.
func waitForFanOut(sink *graphSink, arm string) error {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
graph, _ := sink.snapshot()
if fanOutReady(graph, arm) {
return nil
}
time.Sleep(50 * time.Millisecond)
}
graph, _ := sink.snapshot()
return errUnexpected("a fan-out holding "+wantedText(arm), nodeStates(graph))
}
// fanOutReady is the arm's premise expressed as a graph shape. The completed
// arm waits for every worker to settle, which is the opposite condition from
// the other two rather than a weaker version of it.
func fanOutReady(g agentgraph.Graph, arm string) bool {
if arm == armGraphCompleted {
return allWorkersSettled(g)
}
return holdsAll(g, wantedStates(arm))
}
// wantedStates are the states a mid-flight arm must hold at once. Skipped is
// not among them and cannot be: a fan-out publishes a skip only in its closing
// outcome delta, so nothing reads skipped until the group has ended and nothing
// is running. An item whose dependency already failed sits at pending until
// then, which is what the mixed arm dies holding.
func wantedStates(arm string) []agentgraph.NodeState {
want := []agentgraph.NodeState{agentgraph.StateCompleted, agentgraph.StateRunning}
if arm == armGraphMixed || arm == armUIGraphMixed {
want = append(want, agentgraph.StateFailed, agentgraph.StateAdopted)
}
// The UI arm dies holding a settled fan-out beside a live one, so it also
// reaches the two states the mixed arm cannot: a skip, which is published
// only when a group closes, and an item a ceiling never admitted.
if arm == armUIGraphMixed {
want = append(want, agentgraph.StateSkipped, agentgraph.StatePending, agentgraph.StateQueued)
}
return want
}
func wantedText(arm string) string {
if arm == armGraphCompleted {
return "every worker settled"
}
return statesText(wantedStates(arm))
}
// allWorkersSettled reports whether every worker reached a terminal state. An
// empty state is not terminal here even though NodeState.Terminal calls it one:
// a node declared before it ran carries no state at all, and reading that as
// settled would let the arm die before the fan-out started.
func allWorkersSettled(g agentgraph.Graph) bool {
seen := false
for _, n := range g.Nodes {
if n.Kind != agentgraph.KindWorker {
continue
}
seen = true
if n.State == "" || !n.State.Terminal() {
return false
}
}
return seen
}
// holdsAll reports whether the graph shows at least one worker in each state.
// Group nodes are excluded: a group is the call that owns the fan-out, and its
// own running state would satisfy the running requirement without any child
// being in flight.
func holdsAll(g agentgraph.Graph, want []agentgraph.NodeState) bool {
for _, state := range want {
found := false
for _, n := range g.Nodes {
if n.Kind == agentgraph.KindWorker && n.State == state {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func statesText(states []agentgraph.NodeState) string {
out := make([]string, 0, len(states))
for _, s := range states {
out = append(out, string(s))
}
return join(out)
}
func nodeStates(g agentgraph.Graph) string {
out := make([]string, 0, len(g.Nodes))
for _, n := range g.Nodes {
out = append(out, n.ID+":"+string(n.State))
}
return orNone(join(out))
}
-646
View File
@@ -1,646 +0,0 @@
package main
import (
"fmt"
"reasonix/internal/state/sessionstore"
"slices"
"sort"
"strings"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/runtime/delegation"
"reasonix/internal/state/execgraph"
"reasonix/internal/state/execjournal"
)
// How a run graph can stand after the process that built it exits. Three are
// designs a host may hold; two are defects, and not the same defect. Losing the
// graph costs a reader an explanation; a node still drawn as running with no
// owner costs them the truth — reviving actionable state is more dangerous than
// dropping it, and a running node is actionable state.
const (
graphExact = "reconstructed-exact" // nodes, edges, grants, waits, states all back
graphInterrupted = "interrupted-explicit" // the fan-out is legible and the live child is marked cut
graphLossy = "reconstructed-lossy" // it is back, minus semantics nothing can re-derive
graphLostSilent = "LOST-SILENT" // gone, with no provenance that it ever existed
graphGhost = "GHOST-RUNNING" // still says running, with no live owner anywhere
graphNotMeasured = "not-measured" // the construct phase never established the shape
)
// graphRows classify what a restart inherited from a fan-out. There are two
// outcome rows rather than one because a fan-out that died mid-flight holds two
// populations with different answers: the items that had already settled, whose
// answers the store owns, and the one still executing, which nothing on disk
// has ever been told about. One label over both reports the better half.
func graphRows(before, after Observation) []row {
return []row{
ghostRow(before, after),
dispatchTurnRow(before, after),
settledOutcomeRow(before, after),
interruptedOutcomeRow(before, after),
graphStructureRow(before, after),
graphSemanticsRow(before, after),
childProvenanceRow(before, after),
executionJournalRow(before, after),
rebuildStateRow(before, after),
rebuildTopologyRow(before, after),
rebuildEnvelopeRow(before, after),
rebuildInterruptedRow(before, after),
rebuildIdentityRow(before, after),
executionTopologyRow(before, after),
executionKindRow(before, after),
executionContextRow(before, after),
graphObligationRow(before, after),
}
}
// runningWorkers are the nodes that were executing when the process died. They
// are the arm's premise: an arm that establishes none has measured a fan-out
// nobody interrupted, whatever else it recorded.
func runningWorkers(o Observation) []string {
var out []string
for _, n := range o.Graph.Nodes {
if n.Kind == agentgraph.KindWorker && n.State == agentgraph.StateRunning {
out = append(out, n.ID)
}
}
return out
}
// executedWorkers are the items that ran to a terminal state. Adopted nodes are
// excluded on purpose: nothing executed under them, so the store owes them no
// record and counting them would read a correct store as incomplete.
func executedWorkers(o Observation) []string {
var out []string
for _, n := range o.Graph.Nodes {
if n.Kind != agentgraph.KindWorker || n.State == "" || n.State == agentgraph.StateAdopted {
continue
}
if n.State.Terminal() {
out = append(out, n.ID)
}
}
return out
}
// ghostRow asks the one question with a single acceptable answer: does anything
// the new process can read still present this work as running? A graph node and
// a store record are both such a claim, and neither has an owner behind it —
// the goroutine that would have settled them died with the first process.
func ghostRow(before, after Observation) row {
ghosts := append(runningWorkers(after), childrenInState(after, string(sessionstore.SubagentRunning))...)
verdict := verdictHolds
if len(ghosts) > 0 {
verdict = verdictViolated
}
return row{
Semantic: "work still presented as running", Authority: "the graph and the sub-agent store",
Artifact: "subagents/<ref>.meta.json",
Reconstruction: "nothing may read as running once its owner is gone",
Before: orNone(join(runningWorkers(before))) + " running at death",
After: orNone(join(ghosts)), Verdict: verdict,
}
}
// dispatchTurnRow is the row underneath every other loss here: whether the turn
// that opened the fan-out reached the transcript at all. A turn is appended
// when it ends, so a process that dies inside one takes the request with it —
// and a graph reconstruction has nothing to attach to even in principle.
func dispatchTurnRow(before, after Observation) row {
verdict := verdictPersisted
switch {
case !before.FanOutTurn:
verdict = verdictNotMeasured
case !after.FanOutTurn:
verdict = verdictLost
}
return row{
Semantic: "the turn that dispatched the fan-out", Authority: "session transcript + event log",
Artifact: "<stem>.jsonl, <stem>.events.jsonl", Reconstruction: "appended when the turn ends",
Before: recordedText(before), After: recordedText(after), Verdict: verdict,
}
}
func recordedText(o Observation) string {
if o.FanOutTurn {
return fmt.Sprintf("in the transcript (%d rows)", o.Transcript.Messages)
}
return fmt.Sprintf("absent (%d rows)", o.Transcript.Messages)
}
// settledOutcomeRow classifies the items that had already finished. Their
// answers are what a later turn reads back, so this is the row that decides
// whether a fan-out's history is durable at all.
func settledOutcomeRow(before, after Observation) row {
outcome, verdict := settledOutcome(before, after)
return row{
Semantic: "items that had settled before the death", Authority: "the graph fold and the sub-agent store",
Artifact: "subagents/<ref>.meta.json",
Reconstruction: "one durable child record per item that executed",
Before: fmt.Sprintf("%d executed: %s", len(executedWorkers(before)), graphSummary(before)),
After: outcome, Verdict: verdict,
}
}
func settledOutcome(before, after Observation) (string, string) {
executed := len(executedWorkers(before))
switch {
case len(before.Graph.Nodes) == 0:
return graphNotMeasured, verdictNotMeasured
case graphIdentical(before, after):
return graphExact, verdictExact
case executed == 0:
return graphNotMeasured, verdictNotMeasured
case len(after.Children.Facts) == 0:
return graphLostSilent, verdictViolated
case len(after.Children.Facts) < executed:
return fmt.Sprintf("%s (%d of %d children)", graphLossy, len(after.Children.Facts), executed), verdictLossy
default:
return graphLossy, verdictLossy
}
}
// interruptedOutcomeRow classifies the item that was still executing. This is
// the row the arm exists for: the graph is gone either way, and what separates
// a defensible design from a durable gap is whether anything the new process
// can read still says that work was opened and cut.
func interruptedOutcomeRow(before, after Observation) row {
outcome, verdict := interruptedOutcome(before, after)
return row{
Semantic: "the item still executing at the death", Authority: "the sub-agent store and the transcript",
Artifact: "subagents/<ref>.meta.json, <stem>.jsonl",
Reconstruction: "an interrupted child record, or an unsettled call the transcript still owes",
Before: orNone(join(runningWorkers(before))), After: outcome, Verdict: verdict,
}
}
func interruptedOutcome(before, after Observation) (string, string) {
switch {
case len(before.Graph.Nodes) == 0 || len(runningWorkers(before)) == 0:
return graphNotMeasured, verdictNotMeasured
case len(runningWorkers(after)) > 0 || len(childrenInState(after, string(sessionstore.SubagentRunning))) > 0:
return graphGhost, verdictViolated
case len(after.InterruptedExecutions) > 0:
return graphInterrupted, verdictLossy
case len(childrenInState(after, string(sessionstore.SubagentInterrupted))) > 0:
return graphInterrupted, verdictLossy
case len(after.Obligation.UnansweredCalls) > 0 || after.Obligation.InterruptionMarked > 0:
return graphInterrupted, verdictLossy
default:
return graphLostSilent, verdictViolated
}
}
// graphIdentical compares the whole graph, not its node count. Two graphs with
// the same nodes and different grants are not the same graph, and a comparison
// that only counted would have called that exact.
func graphIdentical(before, after Observation) bool {
return graphNodes(before) == graphNodes(after) &&
graphEdges(before) == graphEdges(after) &&
graphMap(before.Graph.Grants) == graphMap(after.Graph.Grants) &&
graphMap(before.Graph.Waits) == graphMap(after.Graph.Waits)
}
// graphStructureRow is the shape a reader asks for: which nodes, and the typed
// edges between them. Spawn, depends, context and adopt are separate facts, and
// a restart that kept the nodes while losing the edges kept a list, not a graph.
func graphStructureRow(before, after Observation) row {
return valueRow("run graph edges", "event sink (GraphDelta)",
"none observed", "fold GraphDelta events, if any survive",
graphEdges(before), graphEdges(after), false)
}
// graphSemanticsRow is the authority envelope and the reason a node waited.
// They are called out apart from the structure because nothing else in the
// system records them: a child's persisted metadata carries its model and its
// profile, and says nothing about what it was allowed to touch.
func graphSemanticsRow(before, after Observation) row {
return valueRow("run graph grants and waits", "event sink (GraphDelta)",
"none observed", "no durable source carries an authority envelope",
graphEnvelope(before), graphEnvelope(after), false)
}
func graphEnvelope(o Observation) string {
grants, waits := graphMap(o.Graph.Grants), graphMap(o.Graph.Waits)
if grants == "" && waits == "" {
return ""
}
return fmt.Sprintf("grants[%s] waits[%s]", orNone(grants), orNone(waits))
}
// childProvenanceRow is what the store still owns, which is the evidence behind
// an interrupted classification. It is reported as its own row so a reader can
// see the reconstruction the label stands on rather than trusting the label.
func childProvenanceRow(before, after Observation) row {
return valueRow("delegated runs the store owns", "SubagentStore, swept at boot",
"subagents/<ref>.meta.json", "ListSubagentsByParent; CleanupStaleRunning marks stale running children interrupted",
childSummary(before), childSummary(after), true)
}
// graphObligationRow is the last fallback a reader has: a fleet call the
// transcript records with no result is the host saying a fan-out was opened and
// never settled, even when nothing else remembers it.
func graphObligationRow(before, after Observation) row {
return row{
Semantic: "fan-out calls the transcript never settled", Authority: "the canonical transcript",
Artifact: "<stem>.jsonl", Reconstruction: "tool calls with no result",
Before: orNone(join(before.Obligation.UnansweredCalls)),
After: orNone(join(after.Obligation.UnansweredCalls)), Verdict: verdictStable,
}
}
// rebuiltGraph is what the production fold makes of this session's durable
// facts, join included: a record names its own execution, and an older one is
// aliased to its parent call only where the journal opened one by that id.
func rebuiltGraph(o Observation) execgraph.Result {
opened := make(map[string]bool, len(o.Executions))
for _, e := range o.Executions {
opened[e.ID] = true
}
children := make([]execgraph.ChildOutcome, 0, len(o.Children.Facts))
for _, f := range o.Children.Facts {
identity := delegation.ResolveExecutionIdentity(
sessionstore.SubagentMeta{ExecutionID: f.ExecutionID, ParentToolCallID: f.ParentToolCallID},
func(id string) bool { return opened[id] })
children = append(children, execgraph.ChildOutcome{
Execution: identity.Execution, Ref: f.Ref, Status: f.Status,
})
}
return execgraph.Rebuild(o.Executions, children, nil)
}
// rebuildStateRow compares the states the fold produces against the ones the
// dying process drew. Nodes whose owner disappeared are excluded and reported
// by their own row: putting those back as running is the one outcome this
// whole line of work exists to prevent.
func rebuildStateRow(before, after Observation) row {
rebuilt := rebuiltGraph(after)
cut := map[string]bool{}
for _, i := range rebuilt.Interrupted {
cut[i.Execution] = true
}
return valueRow("rebuilt node states", "execgraph.Rebuild over journal + store",
"<stem>.execution.jsonl + subagents/<ref>.meta.json",
"recomputed from durable facts, never restored",
statesExcept(graphOf(before), cut), statesExcept(rebuilt.Graph, cut), false)
}
// rebuildTopologyRow compares every typed edge. Spawn and depends are stated by
// the openings, adopt names its source, and context is derived — so a mismatch
// here says which of those four is not yet recoverable.
func rebuildTopologyRow(before, after Observation) row {
return valueRow("rebuilt topology", "execgraph.Rebuild over journal + store",
"<stem>.execution.jsonl", "spawn and depends stated, adopt named, context derived",
edgeSummary(graphOf(before)), edgeSummary(rebuiltGraph(after).Graph), false)
}
// rebuildEnvelopeRow is the authority and the wait provenance together: what
// each item was allowed to touch, and why admission was first refused.
func rebuildEnvelopeRow(before, after Observation) row {
return valueRow("rebuilt grants and wait causes", "execgraph.Rebuild over journal + store",
"<stem>.execution.jsonl", "recorded at the opening and at the first refusal",
envelopeSummary(graphOf(before)), envelopeSummary(rebuiltGraph(after).Graph), false)
}
// rebuildInterruptedRow accounts for the nodes the state row leaves out. They
// are not dropped: the fold names them as interruptions, split by whether they
// had reached a slot. A node the dying process showed live and the rebuild
// mentions nowhere would be a loss the state row could not see.
func rebuildInterruptedRow(before, after Observation) row {
rebuilt := rebuiltGraph(after)
var got []string
for _, i := range rebuilt.Interrupted {
kind := execjournal.InterruptedBeforeStart
if i.Started {
kind = execjournal.InterruptedDuringExecution
}
got = append(got, i.Execution+":"+kind)
}
sort.Strings(got)
return valueRow("nodes the rebuild will not call live", "execgraph.Rebuild over journal + store",
"<stem>.execution.jsonl", "an execution with no owner is named, never redrawn as running",
liveAtDeath(before), join(got), false)
}
// liveAtDeath is what the dying process still showed as someone's work.
func liveAtDeath(o Observation) string {
var out []string
for _, n := range o.Graph.Nodes {
if n.Kind == agentgraph.KindGroup {
continue
}
switch n.State {
case agentgraph.StateRunning:
out = append(out, n.ID+":"+execjournal.InterruptedDuringExecution)
case agentgraph.StateQueued, agentgraph.StatePending:
out = append(out, n.ID+":"+execjournal.InterruptedBeforeStart)
}
}
sort.Strings(out)
return join(out)
}
// rebuildIdentityRow is what a reader sees on a node beyond its state: the
// label it was dispatched under, and which worker ran it. Only one of those is
// recorded, so this row is where a remaining gap shows up rather than hiding
// inside an exact verdict about states.
func rebuildIdentityRow(before, after Observation) row {
return valueRow("rebuilt node identity", "execgraph.Rebuild over journal + store",
"<stem>.execution.jsonl", "label from the opening; the worker override is recorded nowhere",
identitySummary(graphOf(before)), identitySummary(rebuiltGraph(after).Graph), false)
}
func identitySummary(g agentgraph.Graph) string {
var out []string
for _, n := range g.Nodes {
if n.Kind != agentgraph.KindWorker {
continue
}
out = append(out, fmt.Sprintf("%s[label=%s profile=%s model=%s effort=%s]",
n.ID, orNone(n.Label), orNone(n.Profile), orNone(n.Model), orNone(n.Effort)))
}
sort.Strings(out)
return join(out)
}
// statesExcept renders worker states, skipping the executions whose owner is
// gone. Those are not a state the rebuild may assert, and comparing them would
// demand exactly the ghost this refuses to draw.
func statesExcept(g agentgraph.Graph, skip map[string]bool) string {
var out []string
for _, n := range g.Nodes {
if n.Kind == agentgraph.KindGroup || skip[n.ID] {
continue
}
out = append(out, n.ID+":"+string(n.State))
}
sort.Strings(out)
return join(out)
}
func edgeSummary(g agentgraph.Graph) string {
out := make([]string, 0, len(g.Edges))
for _, e := range g.Edges {
out = append(out, fmt.Sprintf("%s-%s->%s", e.From, e.Kind, e.To))
}
sort.Strings(out)
return join(out)
}
func envelopeSummary(g agentgraph.Graph) string {
var out []string
for _, n := range g.Nodes {
if n.Grant != "" {
out = append(out, n.ID+"="+string(n.Grant))
}
if n.Wait != "" {
out = append(out, n.ID+"@"+string(n.Wait))
}
}
sort.Strings(out)
return join(out)
}
// executionJournalRow is the record written before the work started, which is
// the only artifact here that can outlive an unfinished turn. It is what a
// restart reads to say a delegation existed at all.
func executionJournalRow(before, after Observation) row {
return valueRow("delegations the journal recorded", "execution journal, written before dispatch",
"<stem>.execution.jsonl", "folded from openings and settlings",
executionSummary(before), executionSummary(after), true)
}
// executionTopologyRow is the ordering a restart inherits. An item that never
// started is explained by what it was held behind, so the topology has to
// survive the process that knew it — and survive unchanged, or the explanation
// is a different graph's.
func executionTopologyRow(before, after Observation) row {
return valueRow("declared ordering between items", "the fan-out plan, recorded at the opening",
"<stem>.execution.jsonl", "read back with the openings, never re-derived",
topologySummary(before), topologySummary(after), true)
}
func topologySummary(o Observation) string {
var out []string
for _, e := range o.Executions {
if len(e.DependsOn) == 0 {
continue
}
up := append([]string(nil), e.DependsOn...)
sort.Strings(up)
out = append(out, e.ID+"<-"+strings.Join(up, "+"))
}
if len(out) == 0 {
return ""
}
sort.Strings(out)
return join(out)
}
// executionKindRow holds the two interruptions apart against what the dying
// process actually showed. The item the graph had running must come back as one
// that reached a slot; the one its dependency blocked must come back as one
// that did not. A classification agreeing with neither is a label, not a fact.
func executionKindRow(before, after Observation) row {
running := runningWorkers(before)
verdict := verdictHolds
if len(after.InterruptedExecutions) == 0 {
verdict = verdictNotMeasured
}
for _, e := range after.InterruptedExecutions {
want := execjournal.InterruptedBeforeStart
if slices.Contains(running, e.ID) {
want = execjournal.InterruptedDuringExecution
}
if e.Interruption() != want {
verdict = verdictViolated
}
}
return row{
Semantic: "how each interruption is classified", Authority: "the execution journal's start records",
Artifact: "<stem>.execution.jsonl", Reconstruction: "started at death -> during-execution, never started -> before-start",
Before: orNone(join(running)) + " running at death", After: interruptionKinds(after), Verdict: verdict,
}
}
func interruptionKinds(o Observation) string {
if len(o.InterruptedExecutions) == 0 {
return "none"
}
out := make([]string, 0, len(o.InterruptedExecutions))
for _, e := range o.InterruptedExecutions {
out = append(out, e.ID+":"+e.Interruption())
}
sort.Strings(out)
return join(out)
}
// executionContextRow is the other side of that record: what the next request
// is told. It must state provenance and never offer a continuation, so the
// count is checked against the interruptions rather than against a fixed
// number — a block with nothing behind it is a ghost in prose.
func executionContextRow(before, after Observation) row {
verdict := verdictHolds
if blocksOwed(before) != before.ModelSeesInterruptedExecution ||
blocksOwed(after) != after.ModelSeesInterruptedExecution {
verdict = verdictViolated
}
return row{
Semantic: "what the next request says about them", Authority: "control, projected per request",
Artifact: "none: derived, never stored", Reconstruction: "one block while an interrupted execution stands, none otherwise",
Before: executionBlocks(before), After: executionBlocks(after), Verdict: verdict,
}
}
func blocksOwed(o Observation) int {
if len(o.InterruptedExecutions) > 0 {
return 1
}
return 0
}
func executionBlocks(o Observation) string {
return fmt.Sprintf("%d block(s) for %d interrupted", o.ModelSeesInterruptedExecution, len(o.InterruptedExecutions))
}
func executionSummary(o Observation) string {
if len(o.Executions) == 0 {
return ""
}
open, started := 0, 0
for _, e := range o.Executions {
if e.Open() {
open++
}
if e.Started() {
started++
}
}
return fmt.Sprintf("%d opened, %d started, %d still open, %d with no owner here",
len(o.Executions), started, open, len(o.InterruptedExecutions))
}
func childrenInState(o Observation, status string) []string {
var out []string
for _, f := range o.Children.Facts {
if f.Status == status {
out = append(out, f.Ref)
}
}
return out
}
func childSummary(o Observation) string {
if len(o.Children.Facts) == 0 {
if o.Children.Err != "" {
return "error: " + o.Children.Err
}
return ""
}
counts := map[string]int{}
for _, f := range o.Children.Facts {
counts[f.Status]++
}
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k]))
}
return fmt.Sprintf("%d child(ren): %s", len(o.Children.Facts), strings.Join(parts, " "))
}
// graphSummary is the fan-out as the dying process last saw it, which is the
// "before" every graph row is compared against.
func graphSummary(o Observation) string {
if len(o.Graph.Nodes) == 0 {
return "no fan-out"
}
counts := map[string]int{}
for _, n := range o.Graph.Nodes {
if n.Kind == agentgraph.KindWorker {
counts[string(n.State)]++
}
}
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k]))
}
return fmt.Sprintf("%d node(s), %d edge(s): %s",
len(o.Graph.Nodes), len(o.Graph.Edges), strings.Join(parts, " "))
}
// graphArmInvalid refuses an arm that never reached the state it names. The
// running arms need a child in flight at death; every fan-out arm needs a
// fan-out at all. Reporting a smaller shape as the thing that was interrupted
// is how a probe answers a question nobody asked.
func graphArmInvalid(arm string, before Observation) string {
if len(before.Graph.Nodes) == 0 {
return "no fan-out was published before the process exited, so nothing was measured"
}
if arm == armGraphCompleted {
if len(runningWorkers(before)) > 0 {
return "the fan-out was still running at exit, so this is not the completed arm"
}
return ""
}
if len(runningWorkers(before)) == 0 {
return "no child was executing when the process died, so nothing was interrupted"
}
if arm == armGraphMixed {
return mixedArmInvalid(before)
}
return ""
}
// mixedArmInvalid holds the density arm to its own premise. Its whole claim is
// that one death carried every reachable state at once, and an arm that reached
// three of them measures a different scenario than the one it reports.
func mixedArmInvalid(before Observation) string {
var missing []string
for _, state := range wantedStates(armGraphMixed) {
if !holdsAll(agentgraph.Graph{Nodes: before.Graph.Nodes}, []agentgraph.NodeState{state}) {
missing = append(missing, string(state))
}
}
if len(missing) > 0 {
return "the fan-out never reached " + join(missing) + ", so the arm is not the mixed shape it reports"
}
if !carriesGrant(before, agentgraph.GrantRead) || !carriesGrant(before, agentgraph.GrantWrite) {
return "the fan-out carried only one kind of grant, so the envelope row measures one side of it"
}
return ""
}
func carriesGrant(o Observation, want agentgraph.Grant) bool {
for _, got := range o.Graph.Grants {
if got == string(want) {
return true
}
}
return false
}
// graphEdges renders the typed relations in a stable order. Sorting is not
// cosmetic: the fold keeps first-seen order, which two processes reach by
// different routes, and an unsorted comparison would report a reordering as a
// changed graph.
func graphEdges(o Observation) string {
if len(o.Graph.Edges) == 0 {
return ""
}
out := make([]string, 0, len(o.Graph.Edges))
for _, e := range o.Graph.Edges {
out = append(out, fmt.Sprintf("%s-%s->%s", e.From, e.Kind, e.To))
}
sort.Strings(out)
return join(out)
}
-602
View File
@@ -1,602 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"sort"
"strings"
"sync/atomic"
"time"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/session/control"
)
// The interactive host-initiated arms, named for the one surface they measure: a
// person typing /<skill> into a live session. The headless counterpart is not
// measured here — it is caller-owned and its cancellation need not match, and
// reading one for the other is how a real number gets the wrong subject.
const (
armHostCompleted = "interactive-host-skill-completed"
armHostQueued = "interactive-host-skill-queued-crash"
armHostRunning = "interactive-host-skill-running-crash"
armHostCancel = "interactive-host-skill-cancel"
// The headless counterpart: the controller's own synchronous entry point,
// whose caller owns both the context and the cancellation. It is measured
// apart from the four above and compared to them, never read for them.
armHeadlessCompleted = "headless-host-skill-completed"
armHeadlessRunningCancel = "headless-host-skill-running-cancel"
armHeadlessQueuedCancel = "headless-host-skill-queued-cancel"
)
func headlessHostArm(name string) bool {
switch name {
case armHeadlessCompleted, armHeadlessRunningCancel, armHeadlessQueuedCancel:
return true
}
return false
}
// callerStopArm names the arms a caller stops through its own context. The
// controller never admitted these turns, so its own cancel holds nothing for
// them — reading one surface with the other's stop is a mistake this probe has
// already made once.
func callerStopArm(name string) bool {
return name == armHeadlessRunningCancel || name == armHeadlessQueuedCancel
}
func hostExecArm(name string) bool {
switch name {
case armHostCompleted, armHostQueued, armHostRunning, armHostCancel:
return true
}
return headlessHostArm(name)
}
// queuedHostArm names the arms whose ceiling is filled before the invocation.
func queuedHostArm(name string) bool {
return name == armHostQueued || name == armHeadlessQueuedCancel
}
// The four identities one host-started run can be known by. They are printed
// apart because this is the first surface where they may not coincide: for a
// model-delegated run the execution and the parent call are one string, and
// that has only ever been true because a tool call produced every durable
// execution there was.
const (
probeHostDispatch = "probe:host-dispatch-id"
probeHostChildRef = "probe:host-child-ref"
probeHostParentID = "probe:host-parent-lineage"
probeHostExecution = "probe:host-execution-id"
// probeHostStanding is what the call itself was doing at the reading:
// admitted or held back, its child in the provider or not.
probeHostStanding = "probe:host-standing"
// probeHostAuthority is the snapshot at the same instant: what a reader
// starting from the authority would hold, on either surface.
probeHostAuthority = "probe:host-authority"
)
// hostSlashInput is what a person types. The task carries the child sentinel so
// the scripted provider knows how this arm's child behaves.
func hostSlashInput(arm string) string {
return "/" + probeSkillName + " " + hostChildSentinel(arm) + " host-started work"
}
// runHostExecConstruct drives one interactive slash invocation and dies where
// this arm is about. The queued arm fills the ceiling first with a background
// fleet, which is dispatched through Run rather than Submit: Run is never
// admitted through the turn gate, so the slash the arm measures can still be.
func runHostExecConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
if queuedHostArm(arm) {
go func() { _ = ctrl.Run(ctx, fanOutTurn(turn+1, fleetHolderSentinel)) }()
if err := waitForCeiling(prov); err != nil {
return err
}
}
// Each surface is entered and stopped by the owner it has: the controller's
// gate for a turn it admitted, and the caller's own context for a
// synchronous call it never did.
callerCtx, stopCaller := context.WithCancel(ctx)
defer stopCaller()
var answered atomic.Bool
if headlessHostArm(arm) {
go func() {
_, _ = ctrl.RunSubagentProfile(callerCtx, probeSkillName, hostHeadlessTask(arm), false)
answered.Store(true)
}()
} else {
go ctrl.Submit(hostSlashInput(arm))
}
if err := waitForHostExec(ctrl, prov, arm, &answered); err != nil {
return err
}
stop := ""
switch {
case arm == armHostCancel:
stop = stopHostExec(ctrl, prov)
case callerStopArm(arm):
stop = stopHostCaller(ctrl, prov, arm, stopCaller)
}
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
recordHostIdentities(&obs, ctrl, sink, prov, arm)
if stop != "" {
obs.Progress[probeStopReached] = []string{stop}
}
if err := writeObservation(root, obs); err != nil {
return err
}
os.Exit(0)
return nil
}
// recordHostIdentities writes down all four names this run could be known by,
// at one instant. A single "identity: none" would hide which of them exists and
// which does not, and that difference is the whole question.
func recordHostIdentities(obs *Observation, ctrl *control.Controller, sink *graphSink, prov *scripted, arm string) {
obs.Progress[probeChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(hostChildSentinel(arm))))}
obs.Progress[probeHostDispatch] = []string{orNone(join(hostDispatchIDs(sink)))}
obs.Progress[probeHostChildRef] = []string{orNone(join(hostChildRefs(*obs)))}
obs.Progress[probeHostParentID] = []string{orNone(join(hostParentLineage(*obs)))}
obs.Progress[probeHostExecution] = []string{orNone(join(hostExecutions(*obs)))}
obs.Progress[probeHostStanding] = []string{hostStanding(ctrl, prov, arm)}
// The authority, read at the same instant. It is what a reader starts from
// on either surface, so it is where the two are compared — the delta stream
// exists only where a sink does, and a synchronous caller has none.
obs.Progress[probeHostAuthority] = []string{hostAuthorityNodes(ctrl)}
}
// hostHeadlessTask is what the controller's own entry point is asked to do.
func hostHeadlessTask(arm string) string {
return hostChildSentinel(arm) + " host-started work"
}
func hostAuthorityNodes(ctrl *control.Controller) string {
var out []string
for _, n := range ctrl.ExecutionGraph().Graph.Nodes {
if ofHostExecution(n.ID) {
out = append(out, n.ID+":"+orDash(string(n.State)))
}
}
sort.Strings(out)
return orNone(join(out))
}
// stopHostCaller stops work through the only owner a synchronous call has: the
// context its caller passed in. The controller's gate never admitted this turn,
// so asking it to stop one would be reading a surface with another's stop.
func stopHostCaller(ctrl *control.Controller, prov *scripted, arm string, stop func()) string {
stopped := time.Now()
stop()
child := hostChildSentinel(arm)
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) && !prov.fleets.held(childLeft(child)) {
time.Sleep(50 * time.Millisecond)
}
live := "gone"
if prov.fleets.held(childEntered(child)) && !prov.fleets.held(childLeft(child)) {
live = "still running"
}
return fmt.Sprintf("turn=%s work=%s ctx=%t after=%s", turnStanding(ctrl, arm), live,
prov.fleets.held(childCtxDone(child)), time.Since(stopped).Round(time.Second))
}
func hostChildSentinel(arm string) string {
if arm == armHostCompleted || arm == armHeadlessCompleted {
return childDone
}
return childHang
}
// hostDispatchIDs are the synthetic event ids the host minted so the child's
// activity could nest under something on screen. They are UI identity, not a
// call the model made, which is why no persisted lineage may carry one.
func hostDispatchIDs(sink *graphSink) []string {
var out []string
for id := range sink.toolIDs() {
if strings.HasPrefix(id, "slash-skill-") {
out = append(out, id)
}
}
sort.Strings(out)
return out
}
// hostChildRefs are the store's own artifact ids. They exist only once a child
// transcript has been prepared, which is why they cannot serve as the identity
// of work the scheduler is still holding back.
func hostChildRefs(o Observation) []string {
var out []string
for _, f := range o.Children.Facts {
if f.Kind == "skill" {
out = append(out, f.Ref+"="+f.Status)
}
}
sort.Strings(out)
return out
}
func hostParentLineage(o Observation) []string {
var out []string
for _, f := range o.Children.Facts {
if f.Kind == "skill" {
out = append(out, f.Ref+"→"+orDash(f.ParentToolCallID))
}
}
sort.Strings(out)
return out
}
func hostExecutions(o Observation) []string {
var out []string
for _, e := range o.Executions {
if ofHostExecution(e.ID) || strings.Contains(e.Name, probeSkillName) {
out = append(out, e.ID)
}
}
sort.Strings(out)
return out
}
// ofHostExecution matches both names this work can go by: the execution the host
// owns, and the synthetic event id it mints for the screen. Reading only the
// second is how an arm reports absence when what changed is the identity.
func ofHostExecution(id string) bool {
return strings.HasPrefix(id, "host-") || strings.HasPrefix(id, "slash-skill-")
}
func hostStanding(ctrl *control.Controller, prov *scripted, arm string) string {
return fmt.Sprintf("turn=%v ceiling=%t child=%t", ctrl.Running(),
prov.fleets.held(childEntered(childHold)),
prov.fleets.held(childEntered(hostChildSentinel(arm))))
}
func waitForCeiling(prov *scripted) error {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
if prov.fleets.held(childEntered(childHold)) {
return nil
}
time.Sleep(50 * time.Millisecond)
}
return errUnexpected("a fan-out holding the ceiling", "none reached the provider")
}
// waitForHostExec blocks until the invocation stands where this arm dies. The
// queued arm asks a negative — the child never arrived while the ceiling was
// full — so it settles and asks again rather than reading a race.
func waitForHostExec(ctrl *control.Controller, prov *scripted, arm string, answered *atomic.Bool) error {
child := hostChildSentinel(arm)
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
switch {
case queuedHostArm(arm):
// In flight, read through whichever owner this surface has: a turn
// the controller admitted answers its gate, and a synchronous call
// it never admitted answers only by returning.
if hostInFlight(ctrl, arm, answered) && !prov.fleets.held(childEntered(child)) {
time.Sleep(2 * time.Second)
if prov.fleets.held(childEntered(child)) {
return errUnexpected("a host invocation the scheduler was still holding back", "its child ran")
}
return nil
}
case arm == armHostCompleted:
if prov.fleets.held(childLeft(child)) && !ctrl.Running() {
return nil
}
case arm == armHeadlessCompleted:
if prov.fleets.held(childLeft(child)) {
return nil
}
default:
if prov.fleets.held(childEntered(child)) {
return nil
}
}
time.Sleep(50 * time.Millisecond)
}
return errUnexpected("a host invocation "+hostWanted(arm), hostStanding(ctrl, prov, arm))
}
// hostInFlight reports that the invocation has not answered yet, asked of the
// owner the surface actually has.
func hostInFlight(ctrl *control.Controller, arm string, answered *atomic.Bool) bool {
if headlessHostArm(arm) {
return !answered.Load()
}
return ctrl.Running()
}
func hostWanted(arm string) string {
switch {
case queuedHostArm(arm):
return "held back by a full ceiling"
case arm == armHostCompleted || arm == armHeadlessCompleted:
return "that finished"
}
return "with its child inside the provider"
}
// stopHostExec stops the work the way a person does: the turn's own cancel. A
// slash invocation is admitted through the same gate a typed message is, so
// that gate is the owner of its stop.
func stopHostExec(ctrl *control.Controller, prov *scripted) string {
stopped := time.Now()
ctrl.Cancel()
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) && ctrl.Running() {
time.Sleep(50 * time.Millisecond)
}
live := "gone"
if prov.fleets.held(childEntered(childHang)) && !prov.fleets.held(childLeft(childHang)) {
live = "still running"
}
return fmt.Sprintf("turn=%s work=%s ctx=%t after=%s", turnStanding(ctrl, armHostCancel), live,
prov.fleets.held(childCtxDone(childHang)), time.Since(stopped).Round(time.Second))
}
// The verdicts this arm needs. They are separate words because the failure
// modes are separate facts, and one label over all of them would say less than
// any of them does.
const (
// verdictIdentityGap is work the host admitted that no identity outliving
// the process names. It is not about a lost record: none of the candidate
// identities can carry one.
verdictIdentityGap = "IDENTITY-GAP"
// verdictResultDurable is an invocation whose child record survives while
// the execution that produced it does not. A reader can see that a child of
// this name was cut; nothing says which invocation it belonged to.
verdictResultDurable = "record-only"
)
func hostExecRows(arm string, before, after Observation) []row {
return []row{
hostAdmissionRow(arm, before),
hostIdentityRow(before),
hostGraphRow(before),
hostJournalRow(before, after),
hostStoreRow(before, after),
hostLineageOracleRow(before, after),
hostPreStartIdentityRow(arm, before, after),
hostCancelRow(arm, before),
hostRestartRow(arm, before, after),
}
}
func hostAdmissionRow(arm string, before Observation) row {
entered := firstProgress(before, probeChildEntered) == "true"
answer, verdict := "admitted: the child reached the provider", verdictHolds
switch {
case queuedHostArm(arm) && !entered:
answer = "refused: the ceiling was full and the child never arrived"
case queuedHostArm(arm):
answer, verdict = "not held back: the child ran with the ceiling full", verdictViolated
case !entered:
answer, verdict = "the child never arrived", verdictNotMeasured
}
return row{
Semantic: "does it ask the session scheduler", Authority: skillAuthority,
Artifact: "none observed", Reconstruction: "in-process only",
Before: answer + " [" + firstProgress(before, probeHostStanding) + "]",
After: "—", Verdict: verdict,
}
}
// hostIdentityRow prints all four names at once. This is the row the arm exists
// for: for a model-delegated run the execution and the parent call are the same
// string, and that has only ever held because a tool call produced every durable
// execution there was.
func hostIdentityRow(before Observation) row {
return row{
Semantic: "the four names this run could be known by", Authority: crossAuthority,
Artifact: "the event stream, subagents/<ref>.meta.json, <stem>.execution.jsonl",
Reconstruction: "read apart, because they may not coincide here",
Before: fmt.Sprintf("dispatch=%s child=%s lineage=%s execution=%s",
firstProgress(before, probeHostDispatch), firstProgress(before, probeHostChildRef),
firstProgress(before, probeHostParentID), firstProgress(before, probeHostExecution)),
After: "—", Verdict: verdictHolds,
}
}
func hostGraphRow(before Observation) row {
var drawn []string
for _, n := range before.Graph.Nodes {
if ofHostExecution(n.ID) {
drawn = append(drawn, n.ID+":"+string(n.State))
}
}
sort.Strings(drawn)
// The stream and the authority are reported together because only one of
// them is surface-independent: a synchronous caller has no sink to draw on,
// and a reader on either surface starts from the snapshot regardless.
authority := firstProgress(before, probeHostAuthority)
return row{
Semantic: "the graph, drawn and reconstructable",
Authority: liveAuthority + " and " + authorityName,
Artifact: "<stem>.execution.jsonl", Reconstruction: "the stream where a sink exists; the authority always",
Before: fmt.Sprintf("stream=%s authority=%s", orNone(join(drawn)), authority),
After: "—", Verdict: presenceVerdict(authority != "none"),
}
}
func hostJournalRow(before, after Observation) row {
return presenceRow("does the journal record it", journalAuthority, "<stem>.execution.jsonl",
"ExecutionHistory over the whole session", hostExecutions(before), hostExecutions(after))
}
func hostStoreRow(before, after Observation) row {
return presenceRow("what the store holds for it", storeAuthority, "subagents/<ref>.meta.json",
"ListSubagentsByParent over the transcript stem", hostChildRefs(before), hostChildRefs(after))
}
// hostLineageOracleRow is the contract Step 8 respected and this must not
// weaken. A synthetic host event id is UI identity; persisting one as a parent
// would say the model issued a call it never made.
func hostLineageOracleRow(before, after Observation) row {
leaked := hostSyntheticLeaks(before)
leaked = append(leaked, hostSyntheticLeaks(after)...)
empty := hostLineageEmpty(before) && hostLineageEmpty(after)
answer, verdict := "no persisted lineage names a synthetic host id, and every parent is empty", verdictHolds
switch {
case len(leaked) > 0:
answer, verdict = "a synthetic host id was persisted as lineage: "+join(leaked), verdictViolated
case !empty:
answer, verdict = "a host-started run was filed under a parent call: "+
join(hostParentLineage(after)), verdictViolated
case len(hostChildRefs(before))+len(hostChildRefs(after)) == 0:
verdict = verdictNotMeasured
answer = "no child record exists to carry a lineage"
}
return row{
Semantic: "synthetic host id persisted as lineage", Authority: storeAuthority,
Artifact: "subagents/<ref>.meta.json", Reconstruction: "every persisted parent, read for host event ids",
Before: answer, After: "—", Verdict: verdict,
}
}
func hostSyntheticLeaks(o Observation) []string {
var out []string
for _, f := range o.Children.Facts {
if strings.HasPrefix(f.ParentToolCallID, "slash-skill-") {
out = append(out, f.Ref+"→"+f.ParentToolCallID)
}
}
return out
}
func hostLineageEmpty(o Observation) bool {
for _, f := range o.Children.Facts {
if f.Kind == "skill" && strings.TrimSpace(f.ParentToolCallID) != "" {
return false
}
}
return true
}
// hostPreStartIdentityRow is what decides where an identity would have to be
// created. Work the scheduler is holding back has no child artifact yet, so a
// store ref cannot name it; if nothing else survives the process either, no
// candidate identity exists at the moment the work first became real.
func hostPreStartIdentityRow(arm string, before, after Observation) row {
survives := len(hostExecutions(after)) > 0
answer := "nothing that outlives the process names this work"
verdict := verdictIdentityGap
if survives {
answer, verdict = "an execution the journal opened: "+join(hostExecutions(after)), verdictHolds
}
if !queuedHostArm(arm) && firstProgress(before, probeChildEntered) != "true" {
verdict = verdictNotMeasured
}
return row{
Semantic: "an identity that exists before the child does", Authority: crossAuthority,
Artifact: "<stem>.execution.jsonl",
Reconstruction: "the candidates, read against what the work needs",
Before: hostAtDeath(arm), After: answer, Verdict: verdict,
}
}
func hostAtDeath(arm string) string {
switch {
case queuedHostArm(arm):
return "admitted-domain work, held back, no child artifact yet"
case arm == armHostCompleted || arm == armHeadlessCompleted:
return "ran and returned"
}
return "executing inside the provider"
}
func hostCancelRow(arm string, before Observation) row {
if arm != armHostCancel && !callerStopArm(arm) {
return row{
Semantic: "what a stop reaches", Authority: "the turn's own cancel",
Artifact: "none observed", Reconstruction: "in-process only",
Before: "not stopped in this arm", After: "—", Verdict: verdictNotMeasured,
}
}
return row{
Semantic: "what a stop reaches", Authority: "the turn's own cancel",
Artifact: "subagents/<ref>.meta.json",
Reconstruction: "the live stop and the record it left, kept apart",
Before: firstProgress(before, probeStopReached) + " store=" + orNone(join(hostChildRefs(before))),
After: "—", Verdict: verdictHolds,
}
}
// hostRestartRow classifies what the next process inherits. An answer that
// survived while its execution did not is not a loss to the user, so it is not
// called one; work that was executing and left nothing is.
func hostRestartRow(arm string, before, after Observation) row {
exec, children := hostExecutions(after), hostChildRefs(after)
rebuilt := hostRebuilt(after)
answer := fmt.Sprintf("execution=%s store=%s rebuilt=%s",
orNone(join(exec)), orNone(join(children)), orNone(join(rebuilt)))
return row{
Semantic: "what a restart inherits", Authority: rebuildAuthority,
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
Reconstruction: "execgraph.Rebuild, then the store beside it",
Before: hostAtDeath(arm), After: answer,
Verdict: hostRestartVerdict(arm, before, exec, children),
}
}
// hostRestartVerdict is driven by what a next process can actually hold, not by
// what the arm expected. A child record with nothing to place it is not silence:
// a reader can still see that a child of this name was cut. What it cannot do is
// say which invocation that was, and the identity row answers that separately —
// folding the two would report a surviving record as a total loss.
func hostRestartVerdict(arm string, before Observation, exec, children []string) string {
switch {
case len(exec) > 0:
return verdictHolds
case len(children) > 0:
return verdictResultDurable
case firstProgress(before, probeChildEntered) == "true" || queuedHostArm(arm):
return graphLostSilent
default:
return verdictNotMeasured
}
}
// hostRebuilt is what a restart draws for this invocation. The whole rebuilt
// graph is wider than the arm — the queued arm fills its ceiling with a fan-out,
// and a fan-out records everything — so reading its nodes as the invocation's is
// how an arm reports a gap as closed by evidence belonging to something else.
func hostRebuilt(o Observation) []string {
var out []string
for _, n := range rebuiltGraph(o).Graph.Nodes {
if n.Kind == agentgraph.KindGroup {
continue
}
if ofHostExecution(n.ID) || strings.Contains(n.Label, probeSkillName) {
out = append(out, n.ID+":"+string(n.State))
}
}
sort.Strings(out)
return out
}
// hostExecArmInvalid reports the premise this arm could not establish.
func hostExecArmInvalid(arm string, before Observation) string {
entered := firstProgress(before, probeChildEntered) == "true"
switch {
case queuedHostArm(arm) && entered:
return "the invocation ran while the ceiling was meant to be full"
case !queuedHostArm(arm) && !entered:
return "the invocation's child never reached the provider"
case headlessHostArm(arm) && len(hostDispatchIDs2(before)) > 0:
return "a synchronous call minted a dispatch id, so this was not the headless surface"
case !headlessHostArm(arm) && len(hostDispatchIDs2(before)) == 0:
return "the host minted no dispatch id, so this was not the slash surface"
}
return ""
}
// hostDispatchIDs2 reads the dispatch id back off the observation, where the
// construct phase recorded what only a live sink could see.
func hostDispatchIDs2(o Observation) []string {
if got := firstProgress(o, probeHostDispatch); got != "none" && got != "unrecorded" {
return strings.Split(got, ", ")
}
return nil
}
-163
View File
@@ -1,163 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"time"
"reasonix/internal/session/control"
)
// Two trees, read at once. A stop aimed at a turn must close the turn's own
// tree and leave the job's alone; an explicit job stop does the reverse, and
// reading only one of them is how a probe calls a violated handoff correct. The
// manager's own root needs no separate question: a job still running with its
// child's context open is the root answering directly.
const (
// armLineageForeground is the positive control: it proves the reading can
// see an ordinary cancellation arrive.
armLineageForeground = "lineage-foreground-cancel"
// armLineageBackground is the one under suspicion.
armLineageBackground = "lineage-background-cancel"
// armLineageJobKill is the other positive control: without it, a reading
// that could never observe a job's own cancellation would make the arm
// above pass for the wrong reason.
armLineageJobKill = "lineage-job-kill"
)
func lineageArm(name string) bool {
switch name {
case armLineageForeground, armLineageBackground, armLineageJobKill:
return true
}
return false
}
// runLineageConstruct drives one delegation, stops it the way this arm's owner
// stops it, and reads both trees afterwards. It records six facts, kept apart
// on purpose: a context that closed and a call that returned are different
// events, and only their combination says whether a cancellation propagated or
// something else ended the work.
func runLineageConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
ctrl.Send(fanOutTurn(turn+1, taskSentinel))
if err := waitFor(60*time.Second, func() bool { return prov.fleets.held(childEntered(childHang)) }); err != nil {
return errUnexpected("a delegated child inside the provider", "none arrived")
}
before := lineageReading(ctrl, prov, "before")
switch arm {
case armLineageJobKill:
jobs := ctrl.Jobs()
if len(jobs) == 0 {
return errUnexpected("a background job to stop", "none running")
}
for _, job := range jobs {
ctrl.CancelJob(job.ID)
}
default:
ctrl.Cancel()
}
// Long enough for a cancellation that was going to arrive to have arrived.
time.Sleep(3 * time.Second)
after := lineageReading(ctrl, prov, "after")
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
obs.Progress[probeLineage] = []string{before, after}
if err := writeObservation(root, obs); err != nil {
return err
}
os.Exit(0)
return nil
}
const probeLineage = "probe:lineage"
// lineageReading is the state of both trees at one instant, in the terms the
// rows judge: the turn's gate, the job's own liveness, the child's context, the
// child's call, and the durable record the orchestration keeps.
func lineageReading(ctrl *control.Controller, prov *scripted, at string) string {
return fmt.Sprintf("%s turn=%s job=%s childCtx=%s childCall=%s record=%s",
at, turnGateStanding(ctrl), jobStanding(ctrl),
yesNo(prov.fleets.held(childCtxDone(childHang)), "cancelled", "open"),
yesNo(prov.fleets.held(childLeft(childHang)), "returned", "inside"),
loneRecordStanding(ctrl))
}
func turnGateStanding(ctrl *control.Controller) string {
if ctrl.Running() {
return "active"
}
return "ended"
}
func jobStanding(ctrl *control.Controller) string {
if len(ctrl.Jobs()) > 0 {
return "running"
}
return "gone"
}
func loneRecordStanding(ctrl *control.Controller) string {
for _, e := range control.ExecutionHistory(ctrl.SessionPath()) {
if e.ID == parallelLoneNodeID {
return lifecycleOf(e)[1:]
}
}
return "absent"
}
func yesNo(ok bool, yes, no string) string {
if ok {
return yes
}
return no
}
func waitFor(within time.Duration, ready func() bool) error {
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
if ready() {
return nil
}
time.Sleep(50 * time.Millisecond)
}
return fmt.Errorf("timed out")
}
// lineageRows read the two trees against the ownership each arm is about. The
// three sets of expectations are deliberately different: a fix that closes
// everything on a stop satisfies the foreground arm and violates both others.
func lineageRows(arm string, before Observation) []row {
at := reading(before.Progress[probeLineage], 1)
turnEnded := strings.Contains(at, "turn=ended")
jobLive := strings.Contains(at, "job=running")
childCancelled := strings.Contains(at, "childCtx=cancelled")
recordOpen := !strings.Contains(at, "settled") && !strings.Contains(at, "record=absent")
rows := []row{lineageRow("the turn the stop was aimed at", at, "ends", turnEnded)}
if arm == armLineageForeground {
return append(rows,
lineageRow("the child's own context", at, "closes with the turn that owned it", childCancelled),
lineageRow("the delegation record", at, "is let go of", !recordOpen))
}
if arm == armLineageJobKill {
return append(rows[:0],
lineageRow("the job the stop was aimed at", at, "ends", !jobLive),
lineageRow("the child's own context", at, "closes with the job that owned it", childCancelled),
lineageRow("the delegation record", at, "is let go of", !recordOpen))
}
return append(rows,
lineageRow("the job the turn no longer owns", at, "keeps running", jobLive),
lineageRow("the child's own context", at, "is not closed by another owner's stop", !childCancelled),
lineageRow("the delegation record", at, "stays open while its child runs", recordOpen))
}
func lineageRow(semantic, got, want string, ok bool) row {
return row{
Semantic: semantic, Authority: "both trees, read at the same instant",
Artifact: "none: in-process state", Reconstruction: want,
Before: want, After: orNone(got), Verdict: held(ok),
}
}
-356
View File
@@ -1,356 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"slices"
"time"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/session/control"
)
// The four single-delegation arms. A lone task is not another executor: it
// reaches the same RunProfileSpec a fleet item does and asks the same scheduler
// for a slot. What these ask is whether that shared chain is wired to the
// durable authority the fan-out arms established — and where, exactly, it stops
// being wired, which is a different question from "does it have a graph".
const (
// armTaskCompleted lets the delegation finish and the turn close. Nothing is
// interrupted, so a loss here is not about interruption: it says the run's
// provenance was never durable at all.
armTaskCompleted = "task-completed"
// armTaskRunning kills the process with the child mid-execution, the same
// death the fan-out arms take.
armTaskRunning = "task-running"
// armTaskFgQueued is the live-truth arm: the ceiling is full when the
// delegation asks, so the scheduler is holding it back. What the parent and
// the graph say while that is true is the whole question — a picture that
// says running while nothing has started is wrong before any restart.
armTaskFgQueued = "task-foreground-queued"
// armTaskBgQueued is the one the foreground arms cannot stand in for: a
// background run hands its execution to a job and asks for a slot inside it,
// so the parent tool call has already returned when the refusal happens.
armTaskBgQueued = "task-background-queued"
// armTaskBgRunning is the same handoff with the slot granted, which is where
// ownership of the terminal moves from the caller to the job.
armTaskBgRunning = "task-background-running"
// The cancellation arms. They ask the one question the five before them
// cannot: who owns the ending when work is stopped, on each side of the
// boundary that decides whether it ever ran. A child the scheduler never
// admitted produced nothing, so the store owes it no terminal at all.
armTaskFgQueuedCancel = "task-foreground-queued-cancel"
armTaskBgQueuedCancel = "task-background-queued-cancel"
armTaskFgRunningCancel = "task-foreground-running-cancel"
armTaskBgRunningCancel = "task-background-running-cancel"
)
func cancelTaskArm(name string) bool {
switch name {
case armTaskFgQueuedCancel, armTaskBgQueuedCancel, armTaskFgRunningCancel, armTaskBgRunningCancel:
return true
}
return false
}
// beforeStartArm names the arms cancelled while the scheduler still held the
// delegation, which is the half of the boundary the store must stay out of.
func beforeStartArm(name string) bool {
return name == armTaskFgQueuedCancel || name == armTaskBgQueuedCancel
}
func loneTaskArm(name string) bool {
switch name {
case armTaskCompleted, armTaskRunning, armTaskFgQueued, armTaskBgQueued, armTaskBgRunning:
return true
}
return cancelTaskArm(name)
}
// queuedTaskArm names the arms that fill the ceiling before delegating, so the
// scheduler has to refuse the delegation admission.
func queuedTaskArm(name string) bool {
return name == armTaskFgQueued || name == armTaskBgQueued || beforeStartArm(name)
}
// childEntered names the record a child leaves when it reaches the provider.
// The queued arms ask a negative — that one never did — which nothing else in
// the probe can answer.
func childEntered(sentinel string) string { return "child-entered:" + sentinel }
// childLeft names the record a child leaves when it returns. Entered without
// left is a child still inside the provider — the state a cancellation that
// never arrived leaves behind.
func childLeft(sentinel string) string { return "child-left:" + sentinel }
// childCtxDone names the record a child leaves when its own context closes,
// which is a different fact from having returned: a run whose caller stopped
// waiting returns to nobody with its context still open.
func childCtxDone(sentinel string) string { return "child-ctxdone:" + sentinel }
// probeChildEntered is where that answer rides into the observation. It is not
// a status the kernel emits: the key names the probe so no row reads it as one.
const probeChildEntered = "probe:delegated-child-entered"
func backgroundTaskArm(name string) bool {
switch name {
case armTaskBgQueued, armTaskBgRunning, armTaskBgQueuedCancel, armTaskBgRunningCancel,
armLineageBackground, armLineageJobKill, armCancelBackground, armFleetActiveStore:
return true
}
return false
}
// The sentinels one turn carries. The queued arm names the holder as well: its
// ceiling has to be occupied before the delegation asks for a slot, or the
// refusal it is built to record never happens.
const (
taskSentinel = "PROBE-TASK"
taskCallID = "probe_task"
taskHoldsFirst = fleetHolderSentinel + " " + taskSentinel
)
// loneTaskCall is the delegation itself, dispatched through the tool a model
// calls. Nothing here reaches past the tool's own arguments: the probe measures
// the production path, so it must enter it the way a model does.
func loneTaskCall(arm string) []provider.Chunk {
prompt, description := childDone+" lone task", "completes"
switch {
case arm == armFleetActiveStore:
// This delegation is the other occupant of the ceiling its arm shares
// with a fan-out, and its own child is the only thing that can say the
// slot is taken. Holding is how it hangs and says so at once.
prompt, description = childHold+" lone task", "holds a slot to the death"
case arm != armTaskCompleted:
prompt, description = childHang+" lone task", "still executing at death"
}
args, _ := json.Marshal(map[string]any{
"prompt": prompt, "description": description,
"run_in_background": backgroundTaskArm(arm),
})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: taskCallID, Name: "task", Arguments: string(args)},
}}
}
// probeStepIDs are the host's open steps, in the order they have to be signed.
// The arm whose turn ends has to close them: a delivery is refused while the
// list is open, and a todo_write that marks an item completed on its own is
// refused too — the sign-off is the only door.
func probeStepIDs() []string { return []string{"probe_step_01", "probe_step_02", "probe_step_03"} }
// signStep signs off one step per round, which is what the host accepts: the
// sign-off promotes the next item, so two in one round sign against a list that
// no longer stands.
func signStep(id string) []provider.Chunk {
args, _ := json.Marshal(map[string]any{
"step_id": id,
"result": "the probe drove this step to its end",
"evidence": []map[string]any{
{"kind": "manual", "summary": "the probe established this state directly"},
},
})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: "probe_sign_" + id, Name: "complete_step", Arguments: string(args)},
}}
}
// closesItsTurn names the arms whose turn has to reach its end. A turn that
// called a tool cannot deliver while the host's step list is open, so these
// sign it off one step per round — the sign-off promotes the next item, and two
// in one round would sign against a list that no longer stands.
func closesItsTurn(arm string) bool {
return arm == armTaskCompleted || arm == armSkillCompleted || arm == armReadOnlySkillCompleted
}
// loneTaskSentinels is what this arm's one prompt carries.
func loneTaskSentinels(arm string) string {
if queuedTaskArm(arm) {
return taskHoldsFirst
}
return taskSentinel
}
// runLoneTaskConstruct drives one delegation and dies at the moment this arm is
// about. The completed arm is the exception that proves the rest: it lets the
// turn close, so what it loses was never about dying inside one.
func runLoneTaskConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
// One turn, whatever the arm needs in it: a session admits no second one. The
// queued arm's ceiling is filled by a background fleet named in the same
// prompt, whose own child says when the slot is taken.
prompt := fanOutTurn(turn+1, loneTaskSentinels(arm))
switch {
case arm == armTaskCompleted:
if err := ctrl.Run(ctx, prompt); err != nil {
return fmt.Errorf("delegating turn: %w", err)
}
case guardedArm(arm):
// A stop belongs to whoever admitted the turn, so an arm that is going
// to be stopped is admitted the way a person's input is.
ctrl.Send(prompt)
if err := waitForLoneTask(sink, prov, arm); err != nil {
return err
}
default:
go func() { _ = ctrl.Run(ctx, prompt) }()
if err := waitForLoneTask(sink, prov, arm); err != nil {
return err
}
}
stop := ""
if cancelTaskArm(arm) {
stop = stopDelegation(ctrl, prov, arm)
}
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
if stop != "" {
obs.Progress[probeStopReached] = []string{stop}
}
if queuedTaskArm(arm) {
// Whether the delegation's own child ever ran is the fact the live rows
// are read against, and only the scripted provider knows it.
obs.Progress[probeChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(childHang)))}
}
if err := writeObservation(root, obs); err != nil {
return err
}
os.Exit(0)
return nil
}
// stopDelegation stops the work the way a person does — the turn's own cancel
// for a foreground run, the job list and a kill for a backgrounded one — and
// waits for the orchestration to let go of it. What each layer holds then is
// this arm's whole question, so nothing is read until the record closes.
func stopDelegation(ctrl *control.Controller, prov *scripted, arm string) string {
stopped := time.Now()
if backgroundTaskArm(arm) {
for _, job := range ctrl.Jobs() {
ctrl.CancelJob(job.ID)
}
} else {
ctrl.Cancel()
}
// Wait for the record to close, but do not demand it: whether a stop reaches
// the work it was aimed at is what this arm measures, and an arm that failed
// when it did not would report the finding as its own premise breaking.
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) && !settledLone(ctrl) {
time.Sleep(50 * time.Millisecond)
}
live := "gone"
if prov.fleets.held(childEntered(childHang)) && !prov.fleets.held(childLeft(childHang)) {
live = "still running"
}
return fmt.Sprintf("turn=%s work=%s after=%s", turnStanding(ctrl, arm), live,
time.Since(stopped).Round(time.Second))
}
func settledLone(ctrl *control.Controller) bool {
for _, e := range control.ExecutionHistory(ctrl.SessionPath()) {
if e.ID == parallelLoneNodeID && !e.SettledAt.IsZero() {
return true
}
}
return false
}
// waitForLoneTask blocks until the delegation stands where this arm dies. The
// foreground arms read the live graph; the background ones cannot — a handed-off
// run publishes no node — so they read the progress the parent was told.
func waitForLoneTask(sink *graphSink, prov *scripted, arm string) error {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
if loneTaskReady(sink, prov, arm) {
// A refusal is a state nothing announces, so the arm holds still and
// asks again: a child that was merely slow to start would have
// arrived by now, and the death would be measuring a race.
if beforeStartArm(arm) || arm == armTaskFgQueued {
time.Sleep(2 * time.Second)
return refusalHolding(prov, arm)
}
return nil
}
time.Sleep(50 * time.Millisecond)
}
graph, _ := sink.snapshot()
return errUnexpected("a lone delegation "+loneTaskWanted(arm),
nodeStates(graph)+" progress="+fmt.Sprint(sink.phaseSeries()))
}
// refusalHolding reports the arm's premise once more, after the settle: the
// delegation's child must still not have run.
func refusalHolding(prov *scripted, arm string) error {
if prov.fleets.held(childEntered(childHang)) {
return errUnexpected("a delegation the scheduler was still holding back", "its child ran")
}
return nil
}
func loneTaskReady(sink *graphSink, prov *scripted, arm string) bool {
graph, _ := sink.snapshot()
switch arm {
case armTaskFgQueued, armTaskFgQueuedCancel:
// The node is drawn — the delegation reached the graph — and its child
// has not started, which with the ceiling full is the refusal.
return loneNodeDrawn(graph) && !prov.fleets.held(childEntered(childHang))
case armTaskRunning, armTaskFgRunningCancel, armTaskBgRunningCancel:
return holdsAll(graph, []agentgraph.NodeState{agentgraph.StateRunning})
case armTaskBgQueued, armTaskBgQueuedCancel:
phases := sink.phaseSeries()[taskCallID]
return len(phases) > 0 && phases[len(phases)-1] == string(subagentQueued)
case armTaskBgRunning:
return slices.Contains(sink.phaseSeries()[taskCallID], subagentRunning)
}
return false
}
// loneNodeDrawn reports whether the delegation's own node is on the graph.
func loneNodeDrawn(g agentgraph.Graph) bool {
for _, n := range g.Nodes {
if n.Kind == agentgraph.KindWorker && n.ID == parallelLoneNodeID {
return true
}
}
return false
}
// parallelLoneNodeID is what a lone delegation's node is called: the parent
// call, and the first slot of the fan-out numbering it shares.
const parallelLoneNodeID = taskCallID + "/sub-1"
func loneTaskWanted(arm string) string {
switch arm {
case armTaskRunning, armTaskFgRunningCancel, armTaskBgRunningCancel:
return "running"
case armTaskFgQueued, armTaskFgQueuedCancel, armTaskBgQueuedCancel:
return "drawn while the scheduler holds it back"
case armTaskBgQueued:
return "queued for a slot and not started"
}
return "running in its job"
}
// The two phases the probe waits on. They are the parent-facing statuses a
// background delegation emits, and for a handed-off run they are the only live
// evidence there is.
const (
subagentQueued = "queued"
subagentRunning = "running"
)
// progressPhase reads one subagent status event. The tool id is the parent call
// the delegation was made from, which is also what the graph node hangs under
// and what the sub-agent store records as its parent — the join this arm exists
// to check rather than assume.
func progressPhase(e event.Event) (id, phase string, ok bool) {
if e.Kind != event.ToolProgress || e.Tool.Name != event.SubagentProgressStatusName {
return "", "", false
}
return e.Tool.ID, e.Tool.Output, e.Tool.ID != ""
}
-536
View File
@@ -1,536 +0,0 @@
package main
import (
"fmt"
"sort"
"strings"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/state/execgraph"
"reasonix/internal/state/execjournal"
)
// What a lone delegation leaves behind, layer by layer. The columns are the
// ordinary ones: what the dying process held, and what the next one can still
// say. Nothing here is a fix — the arm exists to find which transition stops
// being recorded, because "single task has no graph" was the roadmap's guess
// and the live chain has since been unified.
func loneTaskRows(arm string, before, after Observation) []row {
return []row{
liveDelegationRow(before),
parentStatusRow(before),
durableOpeningRow(before, after),
durableQueueRow(before, after),
durableStartRow(before, after),
durableSettleRow(before, after),
delegationStoreRow(before, after),
rebuiltDelegationRow(before, after),
rebuiltDelegationIdentityRow(after),
delegationInterruptionRow(before, after),
identityJoinRow(before),
heldBackRow(arm, before),
stoppedOwnershipRow(arm, before),
startedTerminalRow(before, after),
fanOutControlRow(before),
}
}
// ofLoneTask reports whether an id belongs to the delegation under measurement.
// The queued arm fills its ceiling with a fan-out, and a fan-out records
// everything — so without this the rows would read that record as the lone
// task's own and report a gap as closed.
func ofLoneTask(id string) bool {
return id == taskCallID || strings.HasPrefix(id, taskCallID+"/")
}
const (
crossAuthority = "the journal and the store, read against each other"
liveAuthority = "event sink (GraphDelta), in-process"
journalAuthority = "execjournal, written by the orchestration"
storeAuthority = "sub-agent store"
rebuildAuthority = "execgraph.Rebuild over journal + store"
)
// liveDelegationRow is what the dying process actually drew. A backgrounded run
// draws nothing at all — openDelegationGraph returns early for it — so this row
// separates "the graph was lost" from "there was never a node".
func liveDelegationRow(before Observation) row {
var drawn []string
for _, n := range before.Graph.Nodes {
if n.Kind == agentgraph.KindWorker && ofLoneTask(n.ID) {
drawn = append(drawn, n.ID+":"+string(n.State))
}
}
sort.Strings(drawn)
return row{
Semantic: "the delegation the live graph drew", Authority: liveAuthority,
Artifact: "none observed", Reconstruction: "in-process only",
Before: orNone(join(drawn)), After: "—", Verdict: liveVerdict(len(drawn) > 0),
}
}
func liveVerdict(drawn bool) string {
if drawn {
return verdictStable
}
return verdictNotMeasured
}
// parentStatusRow is the other live surface, and the only one a handed-off run
// has: the statuses the parent tool call was told.
func parentStatusRow(before Observation) row {
var told []string
for id, phases := range before.Progress {
if ofLoneTask(id) {
told = append(told, id+":"+strings.Join(phases, "→"))
}
}
sort.Strings(told)
return row{
Semantic: "what the parent call was told", Authority: "subagent progress (ToolProgress)",
Artifact: "none observed", Reconstruction: "in-process only",
Before: orNone(join(told)), After: "—", Verdict: liveVerdict(len(told) > 0),
}
}
func durableOpeningRow(before, after Observation) row {
return valueRow("durable opening", journalAuthority, "<stem>.execution.jsonl",
"read back by ExecutionHistory", executionIDs(before), executionIDs(after), true)
}
func durableQueueRow(before, after Observation) row {
return valueRow("durable queue refusal", journalAuthority, "<stem>.execution.jsonl",
"the scheduler's first refusal, with its cause",
queuedCauses(before), queuedCauses(after), true)
}
func durableStartRow(before, after Observation) row {
return valueRow("durable slot grant", journalAuthority, "<stem>.execution.jsonl",
"written before the child can act", startedIDs(before), startedIDs(after), true)
}
func durableSettleRow(before, after Observation) row {
return valueRow("durable settlement", journalAuthority, "<stem>.execution.jsonl",
"the orchestration letting go of the execution",
settledIDs(before), settledIDs(after), true)
}
// delegationStoreRow is the layer that already survives for a fan-out item. It
// is the reason this arm cannot be read off the graph alone: a store record with
// no opening leaves the run's grant, worker and scheduling unaccounted for.
func delegationStoreRow(before, after Observation) row {
return valueRow("child the store kept", storeAuthority, "subagents/<ref>.meta.json",
"ListSubagentsByParent over the transcript stem",
childFacts(before), childFacts(after), true)
}
func rebuiltDelegationRow(before, after Observation) row {
rebuilt := rebuiltGraph(after)
var nodes []string
for _, n := range rebuilt.Graph.Nodes {
if n.Kind != agentgraph.KindGroup && ofLoneTask(n.ID) {
nodes = append(nodes, n.ID+":"+string(n.State))
}
}
sort.Strings(nodes)
return valueRow("the node a restart rebuilds", rebuildAuthority, "<stem>.execution.jsonl",
"execgraph.Rebuild", loneDelegationAtDeath(before), join(nodes), false)
}
// rebuiltDelegationIdentityRow is what a reader sees on the rebuilt node beyond
// its state. Grant and worker are opening facts: the store cannot answer them,
// so an arm that only compared states would call a hollow node exact.
func rebuiltDelegationIdentityRow(after Observation) row {
var got []string
for _, n := range rebuiltGraph(after).Graph.Nodes {
if n.Kind == agentgraph.KindGroup || !ofLoneTask(n.ID) {
continue
}
got = append(got, fmt.Sprintf("%s grant=%s model=%s effort=%s wait=%s",
n.ID, orDash(string(n.Grant)), orDash(n.Model), orDash(n.Effort), orDash(string(n.Wait))))
}
sort.Strings(got)
return row{
Semantic: "grant, worker and wait on the rebuilt node", Authority: rebuildAuthority,
Artifact: "<stem>.execution.jsonl", Reconstruction: "opening facts, which no store record carries",
Before: "declared at the opening", After: orNone(join(got)),
Verdict: rebuiltIdentityVerdict(got),
}
}
func rebuiltIdentityVerdict(got []string) string {
if len(got) == 0 {
return verdictLost
}
return verdictExact
}
func delegationInterruptionRow(before, after Observation) row {
var got []string
for _, i := range rebuiltGraph(after).Interrupted {
if ofLoneTask(i.Execution) {
got = append(got, i.Execution+":"+interruptionKind(i))
}
}
sort.Strings(got)
return valueRow("the interruption a restart names", rebuildAuthority, "<stem>.execution.jsonl",
"an execution with no owner is named, never redrawn as running",
loneDelegationAtDeath(before), join(got), false)
}
// loneDelegationAtDeath is the delegation as the dying process last knew it,
// from whichever surface knew: a foreground run has a node, a handed-off one
// has only the store's record, and an arm whose rows read the wrong surface
// would report a loss the other one never had.
func loneDelegationAtDeath(o Observation) string {
if live := liveWorkers(o); live != "" {
return live
}
return childFacts(o)
}
func interruptionKind(i execgraph.Interruption) string {
if i.Started {
return execjournal.InterruptedDuringExecution
}
return execjournal.InterruptedBeforeStart
}
// identityJoinRow is the question that decides whether Step 8 needs an identity
// at all: the live node, the call it hung under, the parent the store recorded,
// and the execution the journal opened. Four spellings, or one identity.
func identityJoinRow(before Observation) row {
live, call, stored, opened := identitiesAtDeath(before)
verdict := verdictViolated
switch {
case stored != "" && (stored == opened || stored == call || strings.HasPrefix(live, stored)):
verdict = verdictHolds
case stored == "" && !startedAtDeath(before):
// Nothing executed, so the store owes no record and there is no join to
// make. A missing artifact here is the arm's own premise, not a defect.
verdict = verdictNotMeasured
}
return row{
Semantic: "one identity or four", Authority: "the graph, the call, the store and the journal",
Artifact: "subagents/<ref>.meta.json, <stem>.execution.jsonl",
Reconstruction: "the store's parent call is what a rebuild joins on",
Before: fmt.Sprintf("node=%s call=%s", orDash(live), orDash(call)),
After: fmt.Sprintf("store parent=%s journal=%s", orDash(stored), orDash(opened)),
Verdict: verdict,
}
}
// identitiesAtDeath reads the four spellings out of the dying process's own
// observation, so a join that only holds after a restart cannot be claimed.
func identitiesAtDeath(o Observation) (live, call, stored, opened string) {
for _, n := range o.Graph.Nodes {
if n.Kind == agentgraph.KindWorker && ofLoneTask(n.ID) {
live, call = n.ID, n.ParentID
break
}
}
for id := range o.Progress {
if call == "" && ofLoneTask(id) {
call = id
}
}
for _, f := range o.Children.Facts {
if ofLoneTask(f.ParentToolCallID) {
stored = f.ParentToolCallID
break
}
}
for _, e := range o.Executions {
if ofLoneTask(e.ID) {
opened = e.ID
break
}
}
return live, call, stored, opened
}
// startedAtDeath reports whether the delegation was ever granted a slot.
func startedAtDeath(o Observation) bool {
for _, e := range o.Executions {
if ofLoneTask(e.ID) && e.Started() {
return true
}
}
return false
}
func executionIDs(o Observation) string {
var out []string
for _, e := range o.Executions {
if ofLoneTask(e.ID) {
out = append(out, e.ID)
}
}
sort.Strings(out)
return join(out)
}
func queuedCauses(o Observation) string {
var out []string
for _, e := range o.Executions {
if e.Queued() && ofLoneTask(e.ID) {
out = append(out, e.ID+":"+orDash(e.Cause))
}
}
sort.Strings(out)
return join(out)
}
func startedIDs(o Observation) string {
var out []string
for _, e := range o.Executions {
if e.Started() && ofLoneTask(e.ID) {
out = append(out, e.ID)
}
}
sort.Strings(out)
return join(out)
}
func settledIDs(o Observation) string {
var out []string
for _, e := range o.Executions {
if !e.SettledAt.IsZero() && ofLoneTask(e.ID) {
out = append(out, e.ID)
}
}
sort.Strings(out)
return join(out)
}
func childFacts(o Observation) string {
var out []string
for _, f := range o.Children.Facts {
if ofLoneTask(f.ParentToolCallID) {
out = append(out, f.ParentToolCallID+":"+f.Status)
}
}
sort.Strings(out)
return join(out)
}
func liveWorkers(o Observation) string {
var out []string
for _, n := range o.Graph.Nodes {
if n.Kind == agentgraph.KindWorker && ofLoneTask(n.ID) {
out = append(out, n.ID+":"+string(n.State))
}
}
sort.Strings(out)
return join(out)
}
func orDash(s string) string {
if strings.TrimSpace(s) == "" {
return "-"
}
return s
}
// heldBackRow is the live-truth row: with the ceiling full and the delegation's
// own child never reaching the provider, the scheduler is holding it back — and
// what the picture says while that is true is a claim about now, which no
// restart can correct. Running is the answer that is wrong.
func heldBackRow(arm string, before Observation) row {
if !queuedTaskArm(arm) {
return row{
Semantic: "what the picture says while the scheduler holds it back", Authority: liveAuthority,
Artifact: "none observed", Reconstruction: "this arm never fills the ceiling",
Before: "—", After: "—", Verdict: verdictNotMeasured,
}
}
drawn := orNone(liveWorkers(before))
told := orNone(loneStatus(before))
verdict := verdictHolds
if strings.Contains(drawn, ":running") || told == subagentRunning {
verdict = verdictViolated
}
return row{
Semantic: "what the picture says while the scheduler holds it back", Authority: liveAuthority,
Artifact: "none observed",
Reconstruction: "queued with its cause, or at least not started",
Before: "held back: ceiling full, child never entered",
After: "graph=" + drawn + " parent=" + told, Verdict: verdict,
}
}
func loneStatus(o Observation) string {
for id, phases := range o.Progress {
if ofLoneTask(id) && len(phases) > 0 {
return phases[len(phases)-1]
}
}
return ""
}
// stoppedOwnershipRow is the invariant a stop has to leave behind: once the
// turn is over, nothing it owned may still be open with a live owner. A record
// left open is what a restart reads as an interruption, so a turn that merely
// stopped waiting looks, afterwards, exactly like a process that died.
func stoppedOwnershipRow(arm string, before Observation) row {
if !cancelTaskArm(arm) {
return row{
Semantic: "work the stopped turn still owns", Authority: crossAuthority,
Artifact: "<stem>.execution.jsonl", Reconstruction: "this arm stops nothing",
Before: "—", After: "—", Verdict: verdictNotMeasured,
}
}
reading := ""
if seen := before.Progress[probeStopReached]; len(seen) > 0 {
reading = seen[0]
}
entry, _ := loneEntry(before)
open := entry.SettledAt.IsZero()
turnOver := strings.Contains(reading, "turn=ended")
// An open record implies a live owner: nothing else closes one. The child's
// liveness rides in the reading as evidence, not as the test — a delegation
// still waiting for a slot has no child and is just as much still owned.
return row{
Semantic: "work the stopped turn still owns", Authority: crossAuthority,
Artifact: "<stem>.execution.jsonl",
Reconstruction: "a turn that has ended owns nothing still open",
Before: "the turn owned it when the stop was issued",
After: orNone(reading) + " record=" + entryStanding(entry), Verdict: held(!(turnOver && open)),
}
}
func entryStanding(e execjournal.Entry) string {
if e.SettledAt.IsZero() {
return "still open"
}
return "settled"
}
// startedTerminalRow refuses the combination neither layer may produce: a child
// record for work the journal proves was never admitted. A state comparison
// shows that only as "cancelled came back failed"; this names the cause, which
// is a store asked to speak for an execution that never happened.
func startedTerminalRow(before, after Observation) row {
if startedAtDeath(before) {
return row{
Semantic: "a child record for work that never started", Authority: crossAuthority,
Artifact: "subagents/<ref>.meta.json, <stem>.execution.jsonl",
Reconstruction: "this delegation was granted a slot, so the store owes a record",
Before: "—", After: "—", Verdict: verdictNotMeasured,
}
}
kept := orNone(join(compact(childFacts(before), childFacts(after))))
return row{
Semantic: "a child record for work that never started", Authority: crossAuthority,
Artifact: "subagents/<ref>.meta.json, <stem>.execution.jsonl",
Reconstruction: "no slot was ever granted, so nothing ran and nothing owes a terminal",
Before: "none", After: kept, Verdict: held(kept == "none"),
}
}
func compact(parts ...string) []string {
var out []string
for _, p := range parts {
if strings.TrimSpace(p) != "" {
out = append(out, p)
}
}
return out
}
// fanOutControlRow is the positive control the queued arm carries with it: the
// fleet that fills its ceiling runs through the same scheduler in the same
// session, and records everything. What this arm reports missing is missing at
// the entry point, not in the machinery.
func fanOutControlRow(before Observation) row {
var recorded []string
for _, e := range before.Executions {
if !ofLoneTask(e.ID) {
recorded = append(recorded, e.ID+lifecycleOf(e))
}
}
sort.Strings(recorded)
verdict := verdictNotMeasured
if len(recorded) > 0 {
verdict = verdictPersisted
}
return row{
Semantic: "a fan-out in the same session, same scheduler", Authority: journalAuthority,
Artifact: "<stem>.execution.jsonl", Reconstruction: "control: the machinery this arm reports missing",
Before: orNone(join(recorded)), After: "—", Verdict: verdict,
}
}
func lifecycleOf(e execjournal.Entry) string {
marks := ""
if e.Queued() {
marks += " queued=" + orDash(e.Cause)
}
if e.Started() {
marks += " started"
}
if !e.SettledAt.IsZero() {
marks += " settled"
}
return marks
}
// loneTaskArmInvalid guards each arm's premise: the state it is built to die in
// has to have been reached, or its rows describe a different death.
func loneTaskArmInvalid(arm string, before Observation) string {
phases := strings.Join(before.Progress[taskCallID], "→")
switch arm {
case armTaskCompleted:
if !strings.Contains(phases, "completed") && len(before.Children.Facts) == 0 {
return "the delegation never completed, so this arm measures a different death"
}
case armTaskRunning:
if !holdsState(before, string(agentgraph.StateRunning)) {
return "nothing was executing when the process died"
}
case armTaskFgQueued:
if entered := before.Progress[probeChildEntered]; len(entered) == 0 || entered[0] != "false" {
return "the delegation's child ran, so the scheduler was not holding it back"
}
if !strings.Contains(fanOutControlRow(before).Before, "queued=slots") {
return "no fan-out filled the ceiling, so nothing refused the delegation"
}
case armTaskBgQueued:
if !strings.HasSuffix(phases, subagentQueued) {
return "the delegation was not left queued for a slot: " + orNone(phases)
}
case armTaskBgRunning:
if !strings.Contains(phases, subagentRunning) {
return "the delegation never entered its job: " + orNone(phases)
}
}
if cancelTaskArm(arm) {
return stoppedArmInvalid(arm, before)
}
return ""
}
// stoppedArmInvalid holds a cancellation arm to the side of the boundary it was
// built for. An arm that stopped the work somewhere else measures a different
// ownership question and must say so rather than reporting the answer.
func stoppedArmInvalid(arm string, before Observation) string {
entry, ok := loneEntry(before)
switch {
case !ok:
return "the delegation was never recorded, so there is no ownership to read"
case beforeStartArm(arm) && entry.Started():
return "the delegation was granted a slot before the stop, so this is not a pre-start cancellation"
case !beforeStartArm(arm) && !entry.Started():
return "the delegation never held a slot, so this is not a cancellation of work that ran"
}
return ""
}
func loneEntry(o Observation) (execjournal.Entry, bool) {
for _, e := range o.Executions {
if ofLoneTask(e.ID) {
return e, true
}
}
return execjournal.Entry{}, false
}
-48
View File
@@ -1,48 +0,0 @@
// Process-boundary resurrection probe. It answers one question and modifies
// nothing: after the process that built a session exits, what can a new process
// still prove about that session's runtime semantics without asking a model?
package main
import (
"flag"
"fmt"
"os"
)
func main() {
phase := flag.String("phase", "", "internal: construct|successor|resume, spawned by the orchestrator")
root := flag.String("root", "", "internal: this arm's root directory")
armName := flag.String("arm", "", "internal: this arm's name")
work := flag.String("work", "", "directory to hold arm roots; default is a temp dir")
only := flag.String("only", "", "run a single arm by name")
jsonOut := flag.String("json", "", "also write the machine-readable matrix here")
keep := flag.Bool("keep", false, "keep arm roots on disk after the run")
sabotage := flag.String("sabotage", "", "run under a deliberate defect the arm must fail on (ui-graph-mixed: publish|trajectory)")
flag.Parse()
if *sabotage != "" {
// Carried to the children through the environment they already inherit,
// so no phase has to learn about a flag it does not read.
if err := os.Setenv(envProbeSabotage, *sabotage); err != nil {
fmt.Fprintln(os.Stderr, "runtime-resume:", err)
os.Exit(1)
}
}
var err error
switch *phase {
case "construct":
err = runConstruct(*root, *armName)
case "successor":
err = runSuccessor(*root, *armName)
case "resume":
err = runResume(*root, *armName)
case "":
err = orchestrate(*work, *only, *jsonOut, *keep)
default:
err = fmt.Errorf("unknown phase %q", *phase)
}
if err != nil {
fmt.Fprintln(os.Stderr, "runtime-resume:", err)
os.Exit(1)
}
}
-584
View File
@@ -1,584 +0,0 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"reasonix/internal/state/sessionstore"
"regexp"
"sort"
"strings"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/contract/provider"
"reasonix/internal/runtime/delegation"
"reasonix/internal/safety/evidence"
"reasonix/internal/session/control"
"reasonix/internal/state/execjournal"
"reasonix/internal/state/store"
)
// Observation is everything one phase can read about a session without asking a
// model. Both phases capture the same shape so the matrix is a comparison and
// not two differently-shaped reports.
type Observation struct {
Phase string `json:"phase"`
Arm string `json:"arm"`
SessionPath string `json:"session_path"`
SystemHash string `json:"system_hash"`
SystemBytes int `json:"system_bytes"`
// BootSystemHash is what this process's assembly composed, read before any
// resume; SystemHash is the session's own leading row. Different questions.
BootSystemHash string `json:"boot_system_hash"`
BootSystemBytes int `json:"boot_system_bytes"`
Transcript TranscriptObs `json:"transcript"`
Goal GoalObs `json:"goal"`
Todos []evidence.TodoItem `json:"todos"`
Decisions []control.Decision `json:"decisions"`
Context ContextObs `json:"context"`
Sidecar SidecarObs `json:"sidecar"`
View ViewObs `json:"view"`
TodoNotes TodoNoteObs `json:"todo_notes"`
Deferred DeferredObs `json:"deferred"`
Obligation ObligationObs `json:"obligation"`
// Interrupted is what the host derives from a barrier it found open and did
// not open itself: evidence the wait happened, not a question to answer.
Interrupted []control.InterruptedAdjudication `json:"interrupted"`
// ModelSeesInterruption counts the interruption blocks the request carries,
// which is a different surface from the derived fact behind them.
ModelSeesInterruption int `json:"model_sees_interruption"`
// Journal is how each barrier ended. A superseded edge is the durable
// evidence that a turn received the interruption: nothing else writes one.
Journal []control.AdjudicationEntry `json:"journal,omitempty"`
Graph GraphObs `json:"graph"`
// Children is the durable side of the same fan-out the graph draws. The
// graph has no read surface after a restart, so without this a lost graph
// and a lost delegation would be one indistinguishable row.
Children ChildrenObs `json:"children"`
// Listing is the same children through the read list_subagents performs.
// It filters by workspace and walks lineage, so it can answer differently
// from the one above — and it is the answer a model asking would be shown.
Listing ListingObs `json:"listing"`
// FanOutTurn is whether the dispatching turn is in this phase's transcript
// at all. A turn is appended when it ends, so a process that dies inside one
// leaves no record of the request, and a lost graph is the smaller half.
FanOutTurn bool `json:"fan_out_turn"`
// Executions is the durable record of delegations this turn opened, and
// InterruptedExecutions the host's judgement over it: open, with no owner
// here. Written before the work starts, so it outlives an unfinished turn.
Executions []execjournal.Entry `json:"executions,omitempty"`
InterruptedExecutions []execjournal.Entry `json:"interrupted_executions,omitempty"`
// ModelSeesInterruptedExecution counts the blocks the next request carries
// about them, which is a different surface from the fact behind them.
ModelSeesInterruptedExecution int `json:"model_sees_interrupted_execution"`
// WaitCauses counts the refusals the graph carries, and WaitSeries keeps
// each node's causes in publication order. The fold keeps only the latest,
// which cannot say whether a cause was reported once or replaced.
WaitCauses map[string]int `json:"wait_causes,omitempty"`
WaitSeries map[string][]string `json:"wait_series,omitempty"`
// Progress is every parent-facing subagent status, per call. A background
// delegation draws no node, so this is where its live evidence is.
Progress map[string][]string `json:"progress,omitempty"`
// SettledWorkers and QueuedWorkers are the transition arm's evidence that
// capacity was actually freed while a refusal still stood.
SettledWorkers int `json:"settled_workers"`
QueuedWorkers []string `json:"queued_workers,omitempty"`
Artifacts []ArtifactObs `json:"artifacts"`
// UI is what a frontend attached to this process saw, in order. Only the one
// arm that runs a client fills it; a value comparison cannot answer what it
// asks, which is through which door each fact arrived.
UI *UIObs `json:"ui,omitempty"`
}
type TranscriptObs struct {
Messages int `json:"messages"`
Digest string `json:"digest"`
}
type GoalObs struct {
Goal string `json:"goal"`
Status string `json:"status"`
TurnsUsed int `json:"turns_used"`
}
// ContextObs is the live judgement: CheckpointState is "none" whenever the
// projection did not survive validation, which is what the compaction arms are
// actually asking about.
type ContextObs struct {
ProjectionVersion uint64 `json:"projection_version"`
CheckpointState string `json:"checkpoint_state"`
CanonicalTokens int `json:"canonical_tokens"`
ProjectedTokens int `json:"projected_tokens"`
Blocked bool `json:"blocked"`
}
// SidecarObs is the stored claim, read off disk rather than through the agent,
// so a projection the host refuses to use is still visible as present.
type SidecarObs struct {
Present bool `json:"present"`
Messages int `json:"messages"`
CoveredCount int `json:"covered_count"`
CoveredPrefixHash string `json:"covered_prefix_hash"`
ProjectionVersion uint64 `json:"projection_version"`
TranscriptVersion uint64 `json:"transcript_version"`
Generation uint64 `json:"generation"`
Err string `json:"err,omitempty"`
}
// ViewObs is the model-visible context, spliced the way ContextProjection
// declares it: the stored body followed by canonical[CoveredCount:]. Markers
// are what a fold can be held to — the scripted digest carries none, so a
// marker that is gone was dropped, not summarised.
type ViewObs struct {
Messages int `json:"messages"`
// Markers are the user turns still shown, Echoes the assistant replies. A
// fold takes the reply first, so the two answer different questions.
Markers []string `json:"markers"`
Echoes []string `json:"echoes"`
BodyMarkers []string `json:"body_markers"`
BodyEchoes []string `json:"body_echoes"`
SplicedFromTail int `json:"spliced_from_tail"`
}
// TodoNoteObs locates the host's step-identity note inside the model-visible
// view. BodyLen is where the frozen body ends, so an index below it says the
// note was frozen ahead of the live tail rather than riding it.
type TodoNoteObs struct {
Count int `json:"count"`
Indexes []int `json:"indexes"`
IDs []string `json:"ids"`
BodyLen int `json:"body_len"`
ViewLen int `json:"view_len"`
HostIDs []string `json:"host_ids"`
// ReadableInHistory says the conversation still shows every current id, so
// no note is owed. Without it a count of zero cannot be told apart from a
// lost identity, and the gate would demand a note nobody needs.
ReadableInHistory bool `json:"readable_in_history"`
}
// todoNoteMarker is the opening of todoIdentityNote. The probe reads the view
// the way a model would, so it matches the text rather than a type.
const todoNoteMarker = "Host task state."
var stepIDPattern = regexp.MustCompile(`probe_step_\d+`)
func todoNoteObs(view, history []provider.Message, todos []evidence.TodoItem) TodoNoteObs {
out := TodoNoteObs{BodyLen: len(history), ViewLen: len(view)}
for _, t := range todos {
if t.StepID != "" {
out.HostIDs = append(out.HostIDs, t.StepID)
}
}
out.ReadableInHistory = len(out.HostIDs) > 0
for _, id := range out.HostIDs {
if !mentionsID(history, id) {
out.ReadableInHistory = false
}
}
for i, m := range view {
if !strings.Contains(m.Content, todoNoteMarker) {
continue
}
out.Count++
out.Indexes = append(out.Indexes, i)
out.IDs = append(out.IDs, stepIDPattern.FindAllString(m.Content, -1)...)
}
return out
}
// DeferredObs is the effect a pending decision is holding back. Whether the
// decision itself survives is one question; whether what it blocked stayed
// blocked is the one that cannot be answered by a design choice.
type DeferredObs struct {
MarkerPath string `json:"marker_path"`
Executed bool `json:"executed"`
}
func deferredObs(workspace string) DeferredObs {
path := filepath.Join(workspace, deferredEffect)
_, err := os.Stat(path)
return DeferredObs{MarkerPath: deferredEffect, Executed: err == nil}
}
// ObligationObs is what the transcript itself still says about a turn that
// never finished: a tool call with no result is a claim the host made and did
// not settle, and an interruption record is the host saying so outright. Either
// tells a lost decision apart from a silently dropped one.
type ObligationObs struct {
UnansweredCalls []string `json:"unanswered_calls"`
InterruptionMarked int `json:"interruption_marked"`
}
func obligationObs(msgs []provider.Message) ObligationObs {
answered := map[string]bool{}
var out ObligationObs
for _, m := range msgs {
if m.ToolCallID != "" {
answered[m.ToolCallID] = true
}
if m.InterruptedTurn != nil {
out.InterruptionMarked++
}
}
for _, m := range msgs {
for _, call := range m.ToolCalls {
if call.ID != "" && call.ID != provider.LocalOnlyToolID && !answered[call.ID] {
out.UnansweredCalls = append(out.UnansweredCalls, call.Name+":"+call.ID)
}
}
}
return out
}
type GraphObs struct {
Deltas int `json:"deltas"`
Nodes []agentgraph.Node `json:"nodes,omitempty"`
Edges []agentgraph.Edge `json:"edges,omitempty"`
Grants map[string]string `json:"grants,omitempty"`
Waits map[string]string `json:"waits,omitempty"`
}
// ChildFact is one delegated run as the durable store records it, which is a
// different set of facts from the node the graph drew for it. Whatever the
// graph carried and this does not is a semantic no reconstruction can recover.
type ChildFact struct {
Ref string `json:"ref"`
Status string `json:"status"`
Kind string `json:"kind,omitempty"`
Name string `json:"name,omitempty"`
// ExecutionID is which execution the record belongs to; ParentToolCallID is
// the provider-visible call it descends from. They are separate answers, and
// a host-started run has only the first.
ExecutionID string `json:"executionId,omitempty"`
ParentToolCallID string `json:"parentToolCallId,omitempty"`
Model string `json:"model,omitempty"`
Effort string `json:"effort,omitempty"`
}
// ChildrenObs is every child the store still owns for this parent. It is the
// only durable execution evidence a restart has been shown to read, so a graph
// classification that ignores it would report a loss the host can in fact
// still speak to.
type ChildrenObs struct {
Facts []ChildFact `json:"facts,omitempty"`
Err string `json:"err,omitempty"`
}
// childrenObs reads the store the way a restart does: by parent session id,
// which is the transcript's stem. A session with no path owns nothing.
func childrenObs(root armRoot, sessionPath string) ChildrenObs {
if strings.TrimSpace(sessionPath) == "" {
return ChildrenObs{}
}
parent := strings.TrimSuffix(filepath.Base(sessionPath), ".jsonl")
artifacts, err := sessionstore.ListSubagentsByParent(root.Sessions, parent)
if err != nil {
return ChildrenObs{Err: err.Error()}
}
out := ChildrenObs{}
for _, a := range artifacts {
out.Facts = append(out.Facts, ChildFact{
Ref: a.Ref, Status: string(a.Meta.Status), Kind: a.Meta.Kind, Name: a.Meta.Name,
ExecutionID: a.Meta.ExecutionID,
ParentToolCallID: a.Meta.ParentToolCallID, Model: a.Meta.Model, Effort: a.Meta.Effort,
})
}
sort.Slice(out.Facts, func(i, j int) bool { return out.Facts[i].Ref < out.Facts[j].Ref })
return out
}
// ListingRow is one row list_subagents would print. The tool call itself needs
// a registry this probe has no way to build, so the read behind it is what is
// exercised; the formatting adds no fact the row does not already carry.
type ListingRow struct {
Ref string `json:"ref"`
Status string `json:"status"`
From string `json:"from,omitempty"`
}
// ListingObs is what the recovery surface would show for this conversation.
type ListingObs struct {
Rows []ListingRow `json:"rows,omitempty"`
Err string `json:"err,omitempty"`
}
// listingObs reads the store the way the tool does: by the parent session the
// child recorded, filtered to this workspace. The workspace filter is why this
// cannot be folded into childrenObs — a root that does not match drops every
// row, and a reader must be able to see that happen rather than read it as an
// empty store.
func listingObs(root armRoot, sessionPath string) ListingObs {
if strings.TrimSpace(sessionPath) == "" {
return ListingObs{}
}
store := delegation.NewSubagentStore(filepath.Join(root.Sessions, "subagents"))
parent := strings.TrimSuffix(filepath.Base(sessionPath), ".jsonl")
artifacts, err := store.ListForParent(parent, root.Workspace)
if err != nil {
return ListingObs{Err: err.Error()}
}
out := ListingObs{}
for _, a := range artifacts {
out.Rows = append(out.Rows, ListingRow{
Ref: a.Ref, Status: string(a.Meta.Status), From: a.Meta.ParentToolCallID,
})
}
return out
}
type ArtifactObs struct {
Name string `json:"name"`
Bytes int64 `json:"bytes"`
}
func capture(phase, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, root armRoot) Observation {
path := ctrl.SessionPath()
history := ctrl.History()
system := systemText(history)
snap := ctrl.ContextMaintenanceSnapshot()
st, loaded, loadErr := sessionstore.LoadCompactionState(path)
view, viewMsgs := viewObs(st, loaded, history)
graph, deltas := sink.snapshot()
return Observation{
Phase: phase,
Arm: arm,
SessionPath: path,
SystemHash: shortSum(system),
SystemBytes: len(system),
BootSystemHash: shortSum(bootSystem),
BootSystemBytes: len(bootSystem),
Transcript: TranscriptObs{Messages: len(history), Digest: transcriptDigest(history)},
Goal: GoalObs{Goal: ctrl.Goal(), Status: ctrl.GoalStatus(), TurnsUsed: ctrl.GoalRuntime().TurnsUsed},
Todos: ctrl.Todos(),
Decisions: ctrl.Decisions(),
Context: ContextObs{
ProjectionVersion: snap.ProjectionVersion,
CheckpointState: snap.CheckpointState,
CanonicalTokens: snap.CanonicalTokens,
ProjectedTokens: snap.ProjectedTokens,
Blocked: snap.Blocked,
},
Sidecar: sidecarObs(st, loaded, loadErr),
View: view,
// The note is derived for a request and stored nowhere, so the contracts
// about it are read off the request rather than off the stored view.
TodoNotes: todoNoteObs(ctrl.ModelVisibleMessages(), viewMsgs, ctrl.Todos()),
Graph: graphObs(graph, deltas),
WaitCauses: waitCauseCounts(graph),
WaitSeries: sink.waitSeries(),
Progress: sink.phaseSeries(),
SettledWorkers: settledWorkers(graph),
QueuedWorkers: queuedWorkers(graph),
Children: childrenObs(root, path),
Listing: listingObs(root, path),
FanOutTurn: fanOutTurnRecorded(history),
Executions: control.ExecutionHistory(path),
InterruptedExecutions: ctrl.InterruptedExecutions(),
ModelSeesInterruptedExecution: countBlocks(
ctrl.ModelVisibleMessages(), "<interrupted-execution>"),
Deferred: deferredObs(root.Workspace),
Obligation: obligationObs(history),
Interrupted: ctrl.InterruptedAdjudications(),
Journal: control.AdjudicationHistory(path),
ModelSeesInterruption: countBlocks(ctrl.ModelVisibleMessages(), "<interrupted-adjudication>"),
Artifacts: readArtifacts(path),
}
}
// bootSystemText is what the freshly built assembly composed, read before a
// resume replaces the session under it.
func bootSystemText(ctrl *control.Controller) string { return systemText(ctrl.History()) }
func systemText(msgs []provider.Message) string {
var b strings.Builder
for _, m := range msgs {
if m.Role == provider.RoleSystem {
b.WriteString(m.Content)
b.WriteByte('\n')
}
}
return b.String()
}
// transcriptDigest covers only what reaches a provider, so a local-only marker
// appended by either process cannot read as a changed conversation.
func transcriptDigest(msgs []provider.Message) string {
h := sha256.New()
for _, m := range provider.ModelMessages(msgs) {
b, _ := json.Marshal(struct {
R, C, N string
}{string(m.Role), m.Content, m.Name})
h.Write(b)
h.Write([]byte{'\n'})
}
return hex.EncodeToString(h.Sum(nil))[:16]
}
func shortSum(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:8])
}
func sidecarObs(st sessionstore.CompactionState, ok bool, err error) SidecarObs {
out := SidecarObs{Present: ok}
if err != nil {
out.Err = err.Error()
return out
}
if !ok {
return out
}
out.Messages = len(st.Projection.Messages)
out.CoveredCount = st.Projection.CoveredCount
out.CoveredPrefixHash = st.Projection.CoveredPrefixHash
out.ProjectionVersion = st.Projection.ProjectionVersion
out.TranscriptVersion = st.TranscriptVersion
out.Generation = st.Generation
return out
}
// viewObs splices the model-visible context. With no usable projection the
// view is the canonical transcript, which is what the host would send.
func viewObs(st sessionstore.CompactionState, ok bool, canonical []provider.Message) (ViewObs, []provider.Message) {
var out ViewObs
if !ok || len(st.Projection.Messages) == 0 {
out.Messages = len(canonical)
out.Markers = markersIn(contents(canonical), markerPattern)
out.Echoes = markersIn(contents(canonical), echoPattern)
return out, canonical
}
body := st.Projection.Messages
out.BodyMarkers = markersIn(contents(body), markerPattern)
out.BodyEchoes = markersIn(contents(body), echoPattern)
view := append([]provider.Message(nil), body...)
if n := st.Projection.CoveredCount; n >= 0 && n < len(canonical) {
view = append(view, canonical[n:]...)
out.SplicedFromTail = len(canonical) - n
}
out.Messages = len(view)
out.Markers = markersIn(contents(view), markerPattern)
out.Echoes = markersIn(contents(view), echoPattern)
return out, view
}
// mentionsID reads an id the way the host's own visibility check does: text or
// tool arguments, anywhere in the messages the request carries.
func mentionsID(msgs []provider.Message, id string) bool {
for _, m := range msgs {
if strings.Contains(m.Content, id) {
return true
}
for _, call := range m.ToolCalls {
if strings.Contains(call.Arguments, id) {
return true
}
}
}
return false
}
func countBlocks(msgs []provider.Message, marker string) int {
n := 0
for _, m := range msgs {
if strings.Contains(m.Content, marker) {
n++
}
}
return n
}
func contents(msgs []provider.Message) []string {
out := make([]string, 0, len(msgs))
for _, m := range msgs {
out = append(out, m.Content)
}
return out
}
// waitCauseCounts is how many nodes the graph shows under each refusal. It
// counts nodes rather than reports: a node keeps its cause after it starts, so
// this answers what was refused, not what is refused now.
func waitCauseCounts(g agentgraph.Graph) map[string]int {
out := map[string]int{}
for _, n := range g.Nodes {
if n.Wait != "" {
out[string(n.Wait)]++
}
}
if len(out) == 0 {
return nil
}
return out
}
// queuedWorkers are the items still refused admission, in a stable order.
func queuedWorkers(g agentgraph.Graph) []string {
var out []string
for _, n := range g.Nodes {
if n.Kind != agentgraph.KindGroup && n.State == agentgraph.StateQueued {
out = append(out, n.ID)
}
}
sort.Strings(out)
return out
}
func graphObs(g agentgraph.Graph, deltas int) GraphObs {
out := GraphObs{Deltas: deltas}
if len(g.Nodes) == 0 {
return out
}
out.Nodes = g.Nodes
out.Edges = g.Edges
out.Grants = map[string]string{}
out.Waits = map[string]string{}
for _, n := range g.Nodes {
if n.Grant != "" {
out.Grants[n.ID] = string(n.Grant)
}
if n.Wait != "" {
out.Waits[n.ID] = string(n.Wait)
}
}
return out
}
// readArtifacts lists the session sidecars that exist, which is the evidence
// behind every "persisted" claim in the matrix.
func readArtifacts(sessionPath string) []ArtifactObs {
if sessionPath == "" {
return nil
}
paths := append([]string{sessionPath}, store.SessionSidecarFiles(sessionPath)...)
var out []ArtifactObs
for _, p := range paths {
if p == "" {
continue
}
info, err := os.Stat(p)
if err != nil {
continue
}
out = append(out, ArtifactObs{Name: filepath.Base(p), Bytes: info.Size()})
}
return out
}
func writeObservation(root armRoot, obs Observation) error {
b, err := json.MarshalIndent(obs, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(root.Dir, "obs-"+obs.Phase+".json"), b, 0o644)
}
func readObservation(root armRoot, phase string) (Observation, error) {
var obs Observation
b, err := os.ReadFile(filepath.Join(root.Dir, "obs-"+phase+".json"))
if err != nil {
return obs, err
}
return obs, json.Unmarshal(b, &obs)
}
-338
View File
@@ -1,338 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"reasonix/internal/state/sessionstore"
"time"
"reasonix/internal/runtime/agent"
"reasonix/internal/session/control"
"reasonix/internal/state/checkpoint"
)
// probeTurns is enough conversation for a fold to have a region to work on:
// the system row and the first user turn are pinned, and the recent tail is
// kept verbatim, so a short session has nothing foldable between them.
const probeTurns = 8
// refoldTurns grows the transcript past the first fold by less than the fold
// keeps verbatim, so the second fold has to reach into the stored body.
const refoldTurns = 3
// runTurns drives count turns, each carrying its own marker, and returns the
// next turn number.
func runTurns(ctx context.Context, ctrl *control.Controller, from, count int) (int, error) {
for i := range count {
n := from + i + 1
prompt := fmt.Sprintf("%s Probe turn %d: record the task list and keep working.", marker(n), n)
if err := ctrl.Run(ctx, prompt); err != nil {
return n, fmt.Errorf("turn %d: %w", n, err)
}
}
return from + count, nil
}
// openDecisionAndDie leaves a question open and kills the process while the
// turn is still parked on it. It exits rather than returning: a clean shutdown
// would resolve or cancel the question, which is the one thing a process that
// dies mid-decision does not do.
func openDecisionAndDie(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink) error {
// The gate a person answers through, wired the way chat and desktop wire
// it. Without it the ask tool decides for itself and nothing ever blocks.
ctrl.EnableInteractiveApproval()
go func() { _ = ctrl.Run(ctx, marker(99)+" "+askSentinel+": ask before writing.") }()
if err := waitForAsk(ctrl); err != nil {
return err
}
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
if obs.Deferred.Executed {
return errUnexpected("a write still held back by the open question", obs.Deferred.MarkerPath)
}
if err := writeObservation(root, obs); err != nil {
return err
}
os.Exit(0)
return nil
}
// waitForAsk blocks until the host reports a question waiting on a person.
func waitForAsk(ctrl *control.Controller) error {
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
for _, d := range ctrl.Decisions() {
if d.Kind == control.DecisionAsk {
return nil
}
}
time.Sleep(50 * time.Millisecond)
}
return errUnexpected("a question waiting on a person", ctrl.Decisions())
}
// rewindTurn drives the rewind a person drives, recording the projection
// before it: the tail arm takes back the turn appended after the fold, the
// covered arm lands below the fold boundary and must lose the projection
// whatever coverage says.
func rewindTurn(root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink) error {
points := ctrl.Checkpoints()
if len(points) == 0 {
return errUnexpected("a checkpoint to rewind to", 0)
}
target := points[len(points)-1].Turn
if arm == armCoveredRewind {
var err error
if target, err = turnBelowFold(ctrl, points); err != nil {
return err
}
}
if err := writeObservation(root, capture(extraPhase(arm), arm, bootSystem, ctrl, sink, root)); err != nil {
return err
}
if err := ctrl.Rewind(target, control.RewindConversation); err != nil {
return fmt.Errorf("rewind: %w", err)
}
return nil
}
// turnBelowFold picks the checkpoint whose boundary sits just below the fold,
// read from the sidecar rather than assumed: a control aiming at a fixed turn
// stops removing folded history the moment coverage moves, and says nothing.
// A boundary at the first message is skipped — a session left holding only a
// system row is never persisted, so the restart would read the arm's work back.
func turnBelowFold(ctrl *control.Controller, points []checkpoint.Meta) (int, error) {
covered := 0
if st, ok, _ := sessionstore.LoadCompactionState(ctrl.SessionPath()); ok {
covered = st.Projection.CoveredCount
}
target := -1
for _, p := range points {
plan, err := ctrl.PrepareRewind(p.Turn, control.RewindConversation)
if err != nil || !plan.HasBoundary {
continue
}
if plan.BoundaryIndex > 1 && plan.BoundaryIndex < covered {
target = p.Turn
}
}
if target < 0 {
return 0, errUnexpected("a checkpoint below the fold boundary", covered)
}
return target, nil
}
// extraPhase names the in-process observation an arm records before its
// process exits, empty when it records none.
func extraPhase(arm string) string {
switch arm {
case armRefoldIntoBody:
return "prefold"
case armTailRewind, armCoveredRewind:
return "prerewind"
}
return ""
}
const probeGoal = "Prove which runtime semantics survive an OS process boundary."
// armConstruct hands the arm to the construct path it needs, reporting whether
// one took over. Each of them records its own observation and exits inside the
// turn, so a caller that sees taken has nothing left to do but report the error.
func armConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) (bool, error) {
switch {
case arm == armOpenDecision || successorArm(arm):
return true, openDecisionAndDie(ctx, root, arm, bootSystem, ctrl, sink)
case deriveArm(arm):
return true, runDeriveConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case terminalArm(arm):
return true, runTerminalConstruct(ctx, root, arm, bootSystem, ctrl, sink, turn)
case schedulerWaitArm(arm):
return true, runSchedulerWaitConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case lineageArm(arm):
return true, runLineageConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case cancelRoutingArm(arm):
return true, runCancelRoutingConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case loneTaskArm(arm):
return true, runLoneTaskConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case activeStoreArm(arm):
return true, runActiveStoreConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case skillArm(arm):
return true, runSkillClassConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case ephemeralArm(arm):
return true, runEphemeralConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case hostExecArm(arm):
return true, runHostExecConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case graphArm(arm) || uiArm(arm):
return true, runFanOutConstruct(ctx, root, arm, bootSystem, ctrl, sink, turn)
}
return false, nil
}
// runConstruct establishes host state through the same calls a frontend makes,
// records what this process can see, and returns so the process can exit. It
// never asserts: an arm that failed to establish something is reported as not
// measured, which is the one reading a missing "before" supports.
func runConstruct(dir, arm string) error {
root := rootFor(dir)
if err := root.create(); err != nil {
return err
}
if skillArm(arm) || arm == armEphemeralSkill || hostExecArm(arm) {
if err := writeProbeSkill(root.Workspace); err != nil {
return fmt.Errorf("probe skill: %w", err)
}
}
if total, writers := schedulerLimits(arm); total > 0 {
if err := root.writeProjectConfig(total, writers); err != nil {
return fmt.Errorf("project config: %w", err)
}
}
ctx := context.Background()
sink := &graphSink{}
bus, into := uiBus(arm, sink)
ctrl, prov, err := buildRuntime(ctx, root, arm, into)
if err != nil {
return fmt.Errorf("build: %w", err)
}
defer ctrl.Close()
// The process that dies is a window in production, and a window records what
// it served. Without that the trajectory this session leaves behind holds
// none of its own frames.
recordLikeAWindow(ctrl, bus)
bootSystem := bootSystemText(ctrl)
ctrl.EnsureSessionPath()
if ctrl.SessionPath() == "" {
return errUnexpected("a session path", "")
}
turn, err := runTurns(ctx, ctrl, 0, probeTurns)
if err != nil {
return err
}
if err := ctrl.SetGoalDurable(probeGoal); err != nil {
return fmt.Errorf("goal: %w", err)
}
if _, err := ctrl.Compact(ctx, agent.CompactRequest{Instructions: "Fold the probe turn."}); err != nil {
return fmt.Errorf("compact: %w", err)
}
if taken, err := armConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn); taken {
return err
}
if arm == armTodoIdentity {
// Identity A is already written and folded away. Grow, replace the list
// with identity B, then fold again: the second fold is what decides
// whether the model is left holding one current identity or two.
if turn, err = runTurns(ctx, ctrl, turn, refoldTurns); err != nil {
return err
}
if err := ctrl.Run(ctx, fmt.Sprintf("%s %s: replace the task list.", marker(turn+1), retodoSentinel)); err != nil {
return fmt.Errorf("retodo turn: %w", err)
}
// Grow well past the rewrite so the fold takes the new ids out of view.
// A scenario that leaves them readable asks nothing of the note.
if _, err = runTurns(ctx, ctrl, turn+1, probeTurns); err != nil {
return err
}
if _, err := ctrl.Compact(ctx, agent.CompactRequest{Instructions: "Fold again, after the identity moved."}); err != nil {
return fmt.Errorf("refold: %w", err)
}
} else if arm == armRefoldIntoBody {
// Grow past the fold, record the view a second fold will operate on,
// then fold again: its region reaches back into the stored body, which
// has no canonical counterpart to map a new boundary onto.
if _, err = runTurns(ctx, ctrl, turn, refoldTurns); err != nil {
return err
}
if err := writeObservation(root, capture(extraPhase(arm), arm, bootSystem, ctrl, sink, root)); err != nil {
return err
}
if _, err := ctrl.Compact(ctx, agent.CompactRequest{Instructions: "Fold again, into the stored body."}); err != nil {
return fmt.Errorf("refold: %w", err)
}
} else if appendsAfterFold(arm) {
if _, err = runTurns(ctx, ctrl, turn, 1); err != nil {
return err
}
}
if arm == armTailRewind || arm == armCoveredRewind {
if err := rewindTurn(root, arm, bootSystem, ctrl, sink); err != nil {
return err
}
}
return writeObservation(root, capture("construct", arm, bootSystem, ctrl, sink, root))
}
// successorArm reports whether this arm starts from an interrupted barrier.
func successorArm(arm string) bool {
_, ok := successorTurn(arm)
return ok
}
// runSuccessor is the middle process: it inherits the interruption and does
// what a person would do next — nothing, unrelated work, or something that
// reads like an answer to the dead question. It runs a model on purpose; the
// phase that judges what the host knows still does not.
func runSuccessor(dir, arm string) error {
root := rootFor(dir)
before, err := readObservation(root, "construct")
if err != nil {
return err
}
ctx := context.Background()
sink := &graphSink{}
ctrl, _, err := buildRuntime(ctx, root, arm, sink)
if err != nil {
return fmt.Errorf("build: %w", err)
}
defer ctrl.Close()
bootSystem := bootSystemText(ctrl)
session, err := sessionstore.LoadSession(before.SessionPath)
if err != nil {
return fmt.Errorf("load session: %w", err)
}
if err := ctrl.Resume(session, before.SessionPath); err != nil {
return fmt.Errorf("resume: %w", err)
}
if turn, _ := successorTurn(arm); turn != "" {
if err := ctrl.Run(ctx, turn); err != nil {
return fmt.Errorf("successor turn: %w", err)
}
}
return writeObservation(root, capture("successor", arm, bootSystem, ctrl, sink, root))
}
// runResume boots a second time against the same roots and reads host state.
// It deliberately never calls Run: the question is what the host can prove
// before a provider call, and a model that reconstructs a lost identity from
// the transcript would turn a real loss into a passing row.
func runResume(dir, arm string) error {
root := rootFor(dir)
before, err := readObservation(root, "construct")
if err != nil {
return fmt.Errorf("read construct observation: %w", err)
}
ctx := context.Background()
sink := &graphSink{}
bus, into := uiBus(arm, sink)
ctrl, _, err := buildRuntime(ctx, root, arm, into)
if err != nil {
return fmt.Errorf("build: %w", err)
}
defer ctrl.Close()
bootSystem := bootSystemText(ctrl)
session, err := sessionstore.LoadSession(before.SessionPath)
if err != nil {
return fmt.Errorf("load session: %w", err)
}
if err := ctrl.Resume(session, before.SessionPath); err != nil {
return fmt.Errorf("resume: %w", err)
}
obs := capture("resume", arm, bootSystem, ctrl, sink, root)
if uiArm(arm) {
ui := observeUI(root, ctrl, bus, obs)
obs.UI = &ui
}
return writeObservation(root, obs)
}
-133
View File
@@ -1,133 +0,0 @@
package main
import (
"fmt"
"sort"
"strconv"
"strings"
)
// A fold may only remove a run of markers from the middle: the pinned head
// stays, the retained tail stays, and everything between them becomes the
// digest. Any other shape means the view lost something no fold folded.
const (
verdictContiguous = "contiguous"
verdictHole = "HOLE"
markPrefix = "PROBE-MARK-"
echoPrefix = "PROBE-ECHO-"
)
// refoldRows report what the second fold did. They are their own set because
// the comparison is inside one process — the view before the refold against
// the view after it — not across the process boundary.
func refoldRows(prefold, postfold, resumed Observation, total int) []row {
return []row{
coverageRow("fold 1: stored body and coverage", prefold),
coverageRow("fold 2: stored body and coverage", postfold),
continuityRow("assistant work visible after fold 1", prefold, total),
continuityRow("assistant work visible after fold 2", postfold, total),
continuityRow("assistant work visible after restart", resumed, total),
turnsRow("user turns visible after fold 2", postfold),
}
}
func coverageRow(semantic string, o Observation) row {
return row{
Semantic: semantic, Authority: "compaction sidecar", Artifact: "<stem>.context.json",
Reconstruction: "projection.Messages + canonical[CoveredCount:]",
Before: fmt.Sprintf("canonical=%d", o.Transcript.Messages),
After: fmt.Sprintf("body=%d covered=%d spliced=+%d view=%d",
o.Sidecar.Messages, o.Sidecar.CoveredCount, o.View.SplicedFromTail, o.View.Messages),
Verdict: verdictStable,
}
}
// continuityRow judges the assistant side: a fold replaces one run of replies
// with a digest, so anything but a single surviving run is a loss no fold made.
func continuityRow(semantic string, o Observation, total int) row {
shape := markerShape(o.View.Echoes, echoPrefix)
verdict := verdictContiguous
if reason := markerHole(o.View.Echoes, echoPrefix, total); reason != "" {
verdict, shape = verdictHole, shape+" — "+reason
}
return row{
Semantic: semantic, Authority: "the spliced model-visible view",
Artifact: "none: derived", Reconstruction: "assistant replies surviving in the view",
Before: fmt.Sprintf("emitted 1-%d", total), After: shape, Verdict: verdict,
}
}
// turnsRow reports the user side beside it: the retention budget keeps those
// verbatim, which is why a one-sided tag could not see a dropped reply.
func turnsRow(semantic string, o Observation) row {
return row{
Semantic: semantic, Authority: "the spliced model-visible view",
Artifact: "none: derived", Reconstruction: "user turns surviving in the view",
Before: "kept by the retention budget",
After: markerShape(o.View.Markers, markPrefix), Verdict: verdictStable,
}
}
// pad renders a tag number the way the scripted turns emit it.
func pad(n int) string { return fmt.Sprintf("%03d", n) }
func markerNumbers(markers []string, prefix string) []int {
seen := map[int]bool{}
var out []int
for _, m := range markers {
n, err := strconv.Atoi(strings.TrimPrefix(m, prefix))
if err != nil || seen[n] {
continue
}
seen[n] = true
out = append(out, n)
}
sort.Ints(out)
return out
}
// markerShape renders the surviving markers as runs, which is the form the
// judgement is about: "1, 9-12" says a fold took 2-8.
func markerShape(markers []string, prefix string) string {
nums := markerNumbers(markers, prefix)
if len(nums) == 0 {
return "none"
}
var runs []string
start := nums[0]
for i := 1; i <= len(nums); i++ {
if i < len(nums) && nums[i] == nums[i-1]+1 {
continue
}
if end := nums[i-1]; end == start {
runs = append(runs, strconv.Itoa(start))
} else {
runs = append(runs, fmt.Sprintf("%d-%d", start, end))
}
if i < len(nums) {
start = nums[i]
}
}
return strings.Join(runs, ", ")
}
// markerHole names the violation, or returns "" when the shape is one a fold
// can produce: an optional pinned head starting at 1, then one run reaching the
// last turn. Two gaps, or a tail that stops short of it, are losses.
func markerHole(markers []string, prefix string, total int) string {
nums := markerNumbers(markers, prefix)
if len(nums) == 0 {
return "the view shows no turn at all"
}
runs := strings.Split(markerShape(markers, prefix), ", ")
if last := nums[len(nums)-1]; last != total {
return fmt.Sprintf("the newest turn %d is missing", total)
}
if len(runs) > 2 {
return fmt.Sprintf("%d separate runs; a fold removes one", len(runs))
}
if len(runs) == 2 && nums[0] != 1 {
return fmt.Sprintf("the kept head starts at %d, not the pinned first turn", nums[0])
}
return ""
}
-45
View File
@@ -1,45 +0,0 @@
package main
import "testing"
// The oracle has to be able to fail, or "no hole" says nothing. These are the
// shapes a fold can produce and the shapes only a loss can.
func TestMarkerHoleDiscriminates(t *testing.T) {
echo := func(ns ...int) []string {
out := make([]string, 0, len(ns))
for _, n := range ns {
out = append(out, echoPrefix+pad(n))
}
return out
}
cases := []struct {
name string
in []string
total int
holed bool
}{
{"nothing folded", echo(1, 2, 3, 4, 5), 5, false},
{"middle folded away", echo(4, 5), 5, false},
{"pinned head kept beside the tail", echo(1, 4, 5), 5, false},
{"a reply dropped inside the kept tail", echo(3, 5), 5, true},
{"two gaps", echo(1, 3, 5), 5, true},
{"newest turn missing", echo(3, 4), 5, true},
{"head kept but not the first turn", echo(2, 4, 5), 5, true},
{"empty view", nil, 5, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reason := markerHole(tc.in, echoPrefix, tc.total)
if holed := reason != ""; holed != tc.holed {
t.Fatalf("markerHole(%v) = %q, want holed=%v", tc.in, reason, tc.holed)
}
})
}
}
func TestMarkerShapeRendersRuns(t *testing.T) {
in := []string{echoPrefix + pad(1), echoPrefix + pad(9), echoPrefix + pad(10), echoPrefix + pad(11)}
if got := markerShape(in, echoPrefix); got != "1, 9-11" {
t.Fatalf("markerShape = %q, want %q", got, "1, 9-11")
}
}
-60
View File
@@ -1,60 +0,0 @@
package main
import (
"fmt"
"strings"
)
// renderMatrix prints one table per arm. It reports what each row's value was
// on both sides of the process boundary, because a verdict without the two
// readings behind it is an assertion.
func renderMatrix(results []armResult, work string) string {
var b strings.Builder
b.WriteString("# Runtime resurrection matrix\n\n")
b.WriteString("Measurement boundary: OS process. Phase B loads and inspects; it never calls a model.\n\n")
fmt.Fprintf(&b, "Arm roots: %s\n\n", work)
for _, res := range results {
fmt.Fprintf(&b, "## arm: %s\n\n%s\n\n", res.Arm, res.Asks)
if res.Invalid != "" {
fmt.Fprintf(&b, "**ARM INVALID — %s.** Its rows below establish nothing.\n\n", res.Invalid)
}
b.WriteString("| Semantic | Before death | After new process | Verdict | Authority | Artifact |\n")
b.WriteString("| --- | --- | --- | --- | --- | --- |\n")
for _, r := range res.Rows {
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s | %s |\n",
r.Semantic, cell(r.Before), cell(r.After), r.Verdict, r.Authority, r.Artifact)
}
b.WriteString("\n")
b.WriteString(summarize(res))
b.WriteString("\n")
}
return b.String()
}
func summarize(res armResult) string {
counts := map[string]int{}
for _, r := range res.Rows {
counts[r.Verdict]++
}
var parts []string
// Every verdict the matrix can produce. A summary that silently omits one
// is how a VIOLATED row reads as a clean arm.
for _, v := range []string{verdictPersisted, verdictExact, verdictLossy, verdictLost, verdictNotMeasured,
verdictChanged, verdictStable, verdictContiguous, verdictHole, verdictHolds, verdictViolated} {
if counts[v] > 0 {
parts = append(parts, fmt.Sprintf("%s=%d", v, counts[v]))
}
}
return strings.Join(parts, " ") + "\n"
}
func cell(s string) string {
if strings.TrimSpace(s) == "" {
return "—"
}
s = strings.ReplaceAll(s, "|", "\\|")
if len(s) > 64 {
return s[:61] + "..."
}
return s
}
-323
View File
@@ -1,323 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// Verdicts. They are deliberately not booleans: "the host holds it verbatim"
// and "a fold over canonical artifacts happens to agree" are different claims
// about durability, and a row that was never established supports neither.
const (
verdictPersisted = "persisted-direct"
verdictExact = "reconstructed-exact"
verdictLossy = "reconstructed-lossy"
verdictLost = "lost"
verdictNotMeasured = "not-measured"
verdictChanged = "changed"
verdictStable = "stable"
)
type row struct {
Semantic string `json:"semantic"`
Authority string `json:"authority"`
Artifact string `json:"artifact"`
Reconstruction string `json:"reconstruction"`
Before string `json:"before"`
After string `json:"after"`
Verdict string `json:"verdict"`
}
type armResult struct {
Arm string `json:"arm"`
Asks string `json:"asks"`
Invalid string `json:"invalid,omitempty"`
Rows []row `json:"rows"`
Extra *Observation `json:"extra,omitempty"`
Before Observation `json:"before"`
After Observation `json:"after"`
}
func classify(a arm, extra *Observation, before, after Observation) armResult {
res := armResult{Arm: a.name, Asks: a.asks, Extra: extra, Before: before, After: after}
prefix := prefixRow(before, after)
res.Rows = append(res.Rows, prefix)
if a.name == "system-swap" && prefix.Verdict != verdictChanged {
res.Invalid = "the lever did not move the stable prefix, so this arm measures nothing"
}
if a.name == "exact" && prefix.Verdict != verdictStable {
res.Invalid = "the stable prefix moved without a lever, so this arm is not the control it claims"
}
res.Rows = append(res.Rows,
valueRow("system row in the session", "the transcript's own leading system message",
"<stem>.jsonl", "loaded with the transcript",
before.SystemHash, after.SystemHash, true),
valueRow("canonical transcript", "session transcript + event log",
"<stem>.jsonl, <stem>.events.jsonl", "LoadSession replays the append log",
before.Transcript.Digest, after.Transcript.Digest, true),
valueRow("active goal identity", "goalMachine (control)",
"<stem>.goal-state.json", "restoreFromState on Resume",
before.Goal.Goal, after.Goal.Goal, true),
valueRow("goal status", "goalMachine (control)",
"<stem>.goal-state.json", "restoreFromState on Resume",
before.Goal.Status, after.Goal.Status, true),
valueRow("todo host identity", "agent todoState (host-owned, never in the prompt)",
"none verbatim; goal-state.json snapshots a terminal goal's list",
"rebuilt from the transcript's todo_write / complete_step receipts",
todoIDs(before), todoIDs(after), false),
valueRow("open decisions", "approvalManager (control, in-memory)",
"none", "none: derived from live pending approvals/asks",
decisionIDs(before), decisionIDs(after), false),
valueRow("compaction projection (stored)", "compaction sidecar",
"<stem>.context.json", "LoadProjectionSidecar on bind",
sidecarClaim(before), sidecarClaim(after), true),
useRow("compaction projection (in use)", before, after),
valueRow("run graph nodes", "event sink (GraphDelta)",
"none observed", "fold GraphDelta events, if any survive",
graphNodes(before), graphNodes(after), false),
valueRow("run graph grants", "event sink (GraphDelta)",
"none observed", "fold GraphDelta events, if any survive",
graphMap(before.Graph.Grants), graphMap(after.Graph.Grants), false),
valueRow("run graph wait causes", "event sink (GraphDelta)",
"none observed", "fold GraphDelta events, if any survive",
graphMap(before.Graph.Waits), graphMap(after.Graph.Waits), false),
)
switch {
case successorArm(a.name) && extra != nil:
res.Rows = append(res.Rows, successorRows(a.name, *extra, after)...)
// The premise is a barrier open at death. The construct process is its
// own waiter, so it reports no interruption of its own.
if len(before.Decisions) == 0 {
res.Invalid = "no decision was open when the first process died"
}
case a.name == armOpenDecision:
res.Rows = append(res.Rows, decisionRows(before, after)...)
if len(before.Decisions) == 0 {
res.Invalid = "no decision was open when the process died, so nothing was interrupted"
}
case deriveArm(a.name):
res.Rows = append(res.Rows, deriveRows(a.name, before, after)...)
res.Invalid = deriveArmInvalid(a.name, before)
case terminalArm(a.name):
res.Rows = append(res.Rows, terminalRows(a.name, before, after)...)
res.Invalid = terminalArmInvalid(a.name, before)
case schedulerWaitArm(a.name):
res.Rows = append(res.Rows, schedulerWaitRows(a.name, before, after)...)
res.Invalid = schedulerArmInvalid(a.name, before)
case graphArm(a.name):
res.Rows = append(res.Rows, graphRows(before, after)...)
res.Invalid = graphArmInvalid(a.name, before)
case uiArm(a.name):
res.Rows = append(res.Rows, uiRows(before, after)...)
res.Invalid = uiArmInvalid(before, after)
case lineageArm(a.name):
res.Rows = append(res.Rows, lineageRows(a.name, before)...)
case cancelRoutingArm(a.name):
res.Rows = append(res.Rows, cancelRoutingRows(a.name, before)...)
case loneTaskArm(a.name):
res.Rows = append(res.Rows, loneTaskRows(a.name, before, after)...)
res.Invalid = loneTaskArmInvalid(a.name, before)
case activeStoreArm(a.name):
res.Rows = append(res.Rows, activeStoreRows(before, after)...)
res.Invalid = activeStoreInvalid(before)
case skillArm(a.name):
res.Rows = append(res.Rows, skillClassRows(a.name, before, after)...)
res.Invalid = skillArmInvalid(a.name, before)
case ephemeralArm(a.name):
res.Rows = append(res.Rows, ephemeralRows(a.name, before, after)...)
res.Invalid = ephemeralArmInvalid(before)
case hostExecArm(a.name):
res.Rows = append(res.Rows, hostExecRows(a.name, before, after)...)
res.Invalid = hostExecArmInvalid(a.name, before)
case a.name == armTodoIdentity:
res.Rows = append(res.Rows, todoRows(before, after)...)
case a.name == armRefoldIntoBody && extra != nil:
res.Rows = append(res.Rows, refoldRows(*extra, before, after, probeTurns+refoldTurns)...)
if extra.Sidecar.Messages == 0 {
res.Invalid = "the first fold stored no body, so the second had none to reach into"
}
case (a.name == armTailRewind || a.name == armCoveredRewind) && extra != nil:
res.Rows = append(res.Rows, useRow("projection across the rewind, same process", *extra, before))
res.Rows = append(res.Rows, rewindLandingRow(a.name, *extra, before))
res.Invalid = rewindLandedWrong(a.name, *extra, before)
}
return res
}
// prefixRow is the arm's own control rather than a durability claim: it reports
// whether the stable prefix moved, which is the thing each arm sets up.
func prefixRow(before, after Observation) row {
verdict := verdictStable
if before.BootSystemHash != after.BootSystemHash {
verdict = verdictChanged
}
return row{
Semantic: "stable prefix identity (composed)", Authority: "boot assembly (recomposed per process)",
Artifact: "none (composed from config, memory and instructions)", Reconstruction: "recomposed at boot",
Before: before.BootSystemHash, After: after.BootSystemHash, Verdict: verdict,
}
}
func valueRow(semantic, authority, artifact, reconstruction, before, after string, persisted bool) row {
r := row{Semantic: semantic, Authority: authority, Artifact: artifact,
Reconstruction: reconstruction, Before: before, After: after}
switch {
case before == "" || before == "-":
r.Verdict = verdictNotMeasured
case after == "" || after == "-":
r.Verdict = verdictLost
case before != after:
r.Verdict = verdictLossy
case persisted:
r.Verdict = verdictPersisted
default:
r.Verdict = verdictExact
}
return r
}
func todoIDs(o Observation) string {
var ids []string
for _, t := range o.Todos {
id := t.StepID
if id == "" {
id = "(no step_id)"
}
ids = append(ids, id+":"+t.Status)
}
return join(ids)
}
func decisionIDs(o Observation) string {
var ids []string
for _, d := range o.Decisions {
ids = append(ids, string(d.Kind)+":"+d.ID)
}
return join(ids)
}
func sidecarClaim(o Observation) string {
if !o.Sidecar.Present {
return ""
}
return fmt.Sprintf("v%d msgs=%d covered=%d hash=%s",
o.Sidecar.ProjectionVersion, o.Sidecar.Messages, o.Sidecar.CoveredCount, short(o.Sidecar.CoveredPrefixHash))
}
// rewindLandingRow says where the rewind actually landed relative to the fold.
// An arm that names a side and does not reach it is not measuring that side.
func rewindLandingRow(arm string, prerewind, after Observation) row {
side := "above the fold boundary"
if after.Transcript.Messages < prerewind.Sidecar.CoveredCount {
side = "below the fold boundary"
}
return row{
Semantic: "where the rewind landed", Authority: "checkpoint boundary against the sidecar",
Artifact: "none: derived", Reconstruction: "canonical length after the rewind against CoveredCount before it",
Before: fmt.Sprintf("covered=%d", prerewind.Sidecar.CoveredCount),
After: fmt.Sprintf("canonical=%d, %s", after.Transcript.Messages, side), Verdict: verdictStable,
}
}
// rewindLandedWrong invalidates an arm that aimed at one side of the fold and
// reached the other. covered-rewind stopped removing folded history the moment
// coverage became a real boundary, and nothing in the matrix said so.
func rewindLandedWrong(arm string, prerewind, after Observation) string {
below := after.Transcript.Messages < prerewind.Sidecar.CoveredCount
switch {
case arm == armCoveredRewind && !below:
return "the rewind landed above the fold boundary, so it removed no folded history"
case arm == armTailRewind && below:
return "the rewind landed below the fold boundary, so it is not a tail-only truncation"
}
return ""
}
// useRow turns on whether the next request would ride the projection, not on
// the token counts beside it: a tail edit legitimately moves those, and letting
// them decide the verdict reported a surviving projection as a loss.
func useRow(semantic string, before, after Observation) row {
r := valueRow(semantic, "projectionValid over the covered prefix",
"none: a judgement, not a stored value",
"revalidated against the canonical prefix on every read",
projectionState(before), projectionState(after), false)
r.Before, r.After = projectionUse(before), projectionUse(after)
return r
}
// projectionState is the compared value: in use, not in use, or never measured.
func projectionState(o Observation) string {
switch o.Context.CheckpointState {
case "":
return ""
case "none":
return "NOT-in-use"
}
return "in-use"
}
// projectionUse reads the host's own judgement rather than the stored claim.
// CheckpointState is "none" whenever validation failed, which is the difference
// between a sidecar that loaded and one the next request will actually ride.
// The label itself is not compared — "applied" and "restored" are the same
// answer reached from different sides — so the row turns on use and size.
func projectionUse(o Observation) string {
if o.Context.CheckpointState == "" {
return ""
}
use := "in-use"
if o.Context.CheckpointState == "none" {
use = "NOT-in-use"
}
return fmt.Sprintf("%s projected=%d canonical=%d", use,
o.Context.ProjectedTokens, o.Context.CanonicalTokens)
}
// graphNodes renders the fold's nodes in a stable order. The fold keeps
// first-seen order, which two processes reach by different routes, so an
// unsorted rendering would report a reordering as a changed graph.
func graphNodes(o Observation) string {
if len(o.Graph.Nodes) == 0 {
return ""
}
ids := make([]string, 0, len(o.Graph.Nodes))
for _, n := range o.Graph.Nodes {
ids = append(ids, n.ID+":"+string(n.State))
}
sort.Strings(ids)
return join(ids)
}
// graphMap renders one per-node attribute set. Map iteration is unordered, so
// without the sort two identical envelopes would compare as different.
func graphMap(m map[string]string) string {
if len(m) == 0 {
return ""
}
out := make([]string, 0, len(m))
for k, v := range m {
out = append(out, k+"="+v)
}
sort.Strings(out)
return join(out)
}
func join(in []string) string {
if len(in) == 0 {
return ""
}
return strings.Join(in, ", ")
}
func short(s string) string {
if len(s) <= 12 {
return s
}
return s[:12]
}
func marshalResults(results []armResult) ([]byte, error) {
return json.MarshalIndent(results, "", " ")
}
-265
View File
@@ -1,265 +0,0 @@
package main
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"reasonix/internal/assembly/boot"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/contract/event"
"reasonix/internal/session/control"
)
// armRoot names the four directories one arm owns. Home and Workspace are
// separate so a lever can change what the prefix folds without touching the
// transcript, and Sessions is explicit so the probe never writes to the
// developer's own session directory.
type armRoot struct{ Home, Workspace, Sessions, Dir string }
func rootFor(dir string) armRoot {
return armRoot{
Dir: dir,
Home: filepath.Join(dir, "home"),
Workspace: filepath.Join(dir, "workspace"),
Sessions: filepath.Join(dir, "sessions"),
}
}
func (r armRoot) create() error {
for _, d := range []string{r.Home, r.Workspace, r.Sessions} {
if err := os.MkdirAll(d, 0o755); err != nil {
return err
}
}
return nil
}
// writeProjectConfig puts a project config beside the workspace, which is where
// a person configures these ceilings. The scheduler arms need them small enough
// to reach a specific refusal, and a poked field would prove nothing about the
// judgement production makes.
func (r armRoot) writeProjectConfig(total, writers int) error {
body := fmt.Sprintf("[agent]\nmax_subagent_concurrency = %d\nmax_parallel_writers = %d\n", total, writers)
return os.WriteFile(filepath.Join(r.Workspace, "reasonix.toml"), []byte(body), 0o644)
}
// requireFresh refuses a root a previous run left behind. A reused root is how
// a lever's effect ends up already present in the construct phase, which reads
// as "the lever changed nothing" — a false negative that looks like a result.
func (r armRoot) requireFresh() error {
entries, err := os.ReadDir(r.Dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if len(entries) > 0 {
return fmt.Errorf("arm root %s is not empty; each run needs a clean root", r.Dir)
}
return nil
}
// graphSink records what the run graph published while this process was alive.
// It is the only "before" the graph row has: nothing else in a session's
// artifacts is known to carry it, and a row with no before is not measured.
type graphSink struct {
mu sync.Mutex
graph agentgraph.Graph
deltas int
// waits is every wait cause published per node, in order. The fold keeps
// only the latest, which cannot say whether a cause was reported once or
// replaced — and that is exactly what the transition arm asks.
waits map[string][]string
// phases is every subagent status the parent was told, per call. A run
// handed to a job publishes no graph node at all, so for those this is the
// only live evidence that anything happened.
phases map[string][]string
// tools is when each call was dispatched and when it answered. A stop that
// reached a running tool is visible as the second arriving early.
tools map[string][2]time.Time
}
func (s *graphSink) Emit(e event.Event) {
if e.Kind == event.ToolDispatch || e.Kind == event.ToolResult {
s.noteTool(e)
return
}
if id, phase, ok := progressPhase(e); ok {
s.mu.Lock()
if s.phases == nil {
s.phases = map[string][]string{}
}
s.phases[id] = append(s.phases[id], phase)
s.mu.Unlock()
return
}
if e.Kind != event.GraphDelta || e.Graph == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.deltas++
for _, n := range e.Graph.Nodes {
if n.Wait == "" {
continue
}
if s.waits == nil {
s.waits = map[string][]string{}
}
s.waits[n.ID] = append(s.waits[n.ID], string(n.Wait))
}
s.graph.Apply(*e.Graph)
}
func (s *graphSink) snapshot() (agentgraph.Graph, int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.graph, s.deltas
}
// noteTool records the two edges of a tool call this probe may be asked about.
func (s *graphSink) noteTool(e event.Event) {
if e.Tool.ID == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.tools == nil {
s.tools = map[string][2]time.Time{}
}
at := s.tools[e.Tool.ID]
if e.Kind == event.ToolDispatch {
at[0] = time.Now()
} else {
at[1] = time.Now()
}
s.tools[e.Tool.ID] = at
}
func (s *graphSink) toolDispatched(id string) (time.Time, bool) {
s.mu.Lock()
defer s.mu.Unlock()
at, ok := s.tools[id]
return at[0], ok && !at[0].IsZero()
}
func (s *graphSink) toolResult(id string) (time.Time, bool) {
s.mu.Lock()
defer s.mu.Unlock()
at, ok := s.tools[id]
return at[1], ok && !at[1].IsZero()
}
// phaseSeries is every status published for one call, in publication order.
func (s *graphSink) phaseSeries() map[string][]string {
s.mu.Lock()
defer s.mu.Unlock()
out := map[string][]string{}
for id, seq := range s.phases {
out[id] = append([]string(nil), seq...)
}
return out
}
// waitSeries is every cause published for one node, in publication order.
func (s *graphSink) waitSeries() map[string][]string {
s.mu.Lock()
defer s.mu.Unlock()
out := map[string][]string{}
for id, seq := range s.waits {
out[id] = append([]string(nil), seq...)
}
return out
}
// buildRuntime assembles a real runtime bound to this arm's roots. Everything a
// frontend would supply is supplied; only the provider is scripted, so the
// probe never depends on a network or a key.
func buildRuntime(ctx context.Context, root armRoot, arm string, sink event.Sink) (*control.Controller, *scripted, error) {
prov := newScripted(arm)
ctrl, err := boot.Build(ctx, boot.Options{
Version: "runtime-resume-probe",
Model: probeModelRef,
Sink: sink,
WorkspaceRoot: root.Workspace,
Home: root.Home,
SessionDir: root.Sessions,
ProviderResolver: resolverFor(prov),
Stderr: io.Discard,
HeadlessApprovalMode: approvalMode(arm),
})
if err != nil {
return nil, nil, err
}
ctrl.ApplyHeadlessApprovalMode(approvalMode(arm))
return ctrl, prov, nil
}
// approvalMode is the gate this arm needs. Everything else runs denied, the
// strictest a headless frontend can be. A fleet is not read-only, so under a
// denied gate the dispatch is refused before any child starts and the arm
// measures a permission decision instead of a process boundary.
func approvalMode(arm string) string {
if graphArm(arm) || uiArm(arm) || loneTaskArm(arm) || cancelRoutingArm(arm) || lineageArm(arm) ||
schedulerWaitArm(arm) || terminalArm(arm) || deriveArm(arm) || activeStoreArm(arm) ||
skillArm(arm) || ephemeralArm(arm) || hostExecArm(arm) {
return control.ToolApprovalAuto
}
return "deny"
}
// childEnv binds the child process to this arm's home through the environment
// as well as through Options.Home: a Controller's later re-reads and the shared
// history index still read the process, not the assembly.
func childEnv(root armRoot) []string {
env := append(os.Environ(),
"REASONIX_HOME="+root.Home,
"REASONIX_STATE_HOME="+root.Home,
)
// A child runs in its own workspace, so the one arm that has to build the
// frontend is told where the checkout is rather than searching for it.
if repo := repoRoot(); repo != "" {
env = append(env, envProbeRepo+"="+repo)
}
return env
}
// repoRoot walks up from the orchestrator's working directory for the module
// this probe lives in. Empty when it is not run from a checkout, which the arm
// reports as not measured rather than as a failure.
func repoRoot() string {
dir, err := os.Getwd()
if err != nil {
return ""
}
for {
if b, err := os.ReadFile(filepath.Join(dir, "go.mod")); err == nil &&
strings.HasPrefix(string(b), "module reasonix") {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}
// toolIDs is every call this sink saw dispatched, which is where a host-minted
// event id first appears: it is not in the graph and not in the transcript.
func (s *graphSink) toolIDs() map[string]bool {
s.mu.Lock()
defer s.mu.Unlock()
out := make(map[string]bool, len(s.tools))
for id := range s.tools {
out[id] = true
}
return out
}
-376
View File
@@ -1,376 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"sort"
"strings"
"time"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/session/control"
)
// The scheduler-wait arms. Each dies with one item ready and refused admission,
// and they are separate because the scheduler decides in a fixed order —
// slots, then writers, then claim. An arm that let an earlier cause stand would
// report it as the later one, so each has to clear every check above the one it
// is measuring.
const (
// armWaitSlots fills the session's total ceiling with runs that never end.
// The refused item is read-only: it clears every writer check by not being
// a writer at all.
armWaitSlots = "wait-slots"
// armWaitWriters leaves total capacity free and fills the writer ceiling.
// Its two writers declare disjoint paths, so a claim conflict cannot be
// what refuses the second one.
armWaitWriters = "wait-writers"
// armWaitClaim leaves both ceilings free and overlaps two write paths. A
// single fan-out cannot reach this: preflight refuses concurrent writers
// whose claims overlap, so the holder is dispatched as a background fleet
// and the refused writer comes from a later one.
armWaitClaim = "wait-claim"
// armWaitTransition asks what a wait cause means once the thing it named
// stops being what holds the item back. The scheduler reports the cause
// once, when the request queues; the blocker can change afterwards.
armWaitTransition = "wait-transition"
)
func schedulerWaitArm(name string) bool {
switch name {
case armWaitSlots, armWaitWriters, armWaitClaim, armWaitTransition:
return true
}
return false
}
// schedulerLimits are the ceilings an arm needs to reach its refusal. They are
// written to the project config, so the run makes the same judgement a person
// configuring the session would get.
func schedulerLimits(arm string) (total, writers int) {
switch arm {
case armTerminalCancelled:
// One slot, so the group has an item running and items that never got
// one — the split a cancellation is reported against.
return 1, 1
case armLineageBackground, armLineageJobKill:
// Room for a second run: the alive check asks whether the manager can
// still start anything, and a ceiling of one would answer for the
// hanging child instead.
return 4, 4
case armTaskFgQueued, armTaskBgQueued, armTaskFgQueuedCancel, armTaskBgQueuedCancel:
// One slot, held by the fleet dispatched ahead of the delegation, so the
// refusal the arm records is the scheduler's and not a race.
return 1, 1
case armUIGraphMixed:
// One slot, so the item after the one that hangs is refused: the death
// then holds a node that was queued and never admitted.
return 1, 1
case armHostQueued, armHeadlessQueuedCancel:
// One slot, held by the fan-out dispatched ahead of the invocation.
return 1, 1
case armSkillQueued, armReadOnlySkillQueued:
// One slot, held by the fleet dispatched ahead of the skill, so the
// refusal the arm reads is the scheduler's and not a race.
return 1, 1
case armFleetActiveStore:
// Two slots: the delegation holds one to the death, and the fan-out's
// first item frees the other for exactly one of the two behind it.
return 2, 2
case armWaitSlots:
return 2, 1
case armWaitWriters:
return 6, 1
case armWaitClaim:
return 6, 3
case armWaitTransition:
return 3, 1
}
return 0, 0
}
// wantedCause is the refusal this arm exists to produce. The transition arm
// starts at slots and asks what happens to that answer afterwards.
func wantedCause(arm string) agentgraph.WaitCause {
switch arm {
case armWaitWriters:
return agentgraph.WaitWriters
case armWaitClaim:
return agentgraph.WaitClaim
}
return agentgraph.WaitSlots
}
// waitingWorkers are the nodes the scheduler refused and has not admitted: the
// arm's premise. State is checked as well as the cause, because a node that
// went on to run still carries the cause it waited under.
func waitingWorkers(g agentgraph.Graph, cause agentgraph.WaitCause) []string {
var out []string
for _, n := range g.Nodes {
if n.Kind == agentgraph.KindGroup || n.State != agentgraph.StateQueued {
continue
}
if n.Wait == cause {
out = append(out, n.ID)
}
}
sort.Strings(out)
return out
}
// waitForRefusal blocks until one item is queued under this arm's cause.
func waitForRefusal(sink *graphSink, arm string) error {
cause := wantedCause(arm)
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
graph, _ := sink.snapshot()
if len(waitingWorkers(graph, cause)) > 0 {
return nil
}
time.Sleep(50 * time.Millisecond)
}
graph, _ := sink.snapshot()
return errUnexpected("an item queued under "+string(cause), waitStates(graph))
}
func waitStates(g agentgraph.Graph) string {
out := make([]string, 0, len(g.Nodes))
for _, n := range g.Nodes {
out = append(out, fmt.Sprintf("%s:%s/%s", n.ID, n.State, orNone(string(n.Wait))))
}
sort.Strings(out)
return orNone(join(out))
}
// schedTurn is the prompt that opens this arm's fan-out. The slots arm needs
// only one fleet; the others name both, and the latch order dispatches the
// holder first while the refused fleet waits on the holder's own signal.
func schedTurn(n int, arm string) string {
if arm == armWaitSlots {
return fmt.Sprintf("%s %s: dispatch the fan-out.", marker(n), fleetPairSentinel)
}
return fmt.Sprintf("%s %s %s: dispatch the fan-out.", marker(n), fleetHolderSentinel, fleetRefusedSentinel)
}
// runSchedulerWaitConstruct drives the arm to its refusal and exits while the
// item is still queued. The transition arm goes further: it frees the capacity
// the refusal named and asks what the reported cause does about it.
func runSchedulerWaitConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
go func() { _ = ctrl.Run(ctx, schedTurn(turn+1, arm)) }()
if err := waitForRefusal(sink, arm); err != nil {
return err
}
if arm == armWaitTransition {
if err := releaseAndSettle(sink, prov); err != nil {
return err
}
}
if err := writeObservation(root, capture("construct", arm, bootSystem, ctrl, sink, root)); err != nil {
return err
}
os.Exit(0)
return nil
}
// releaseAndSettle frees the run that filled total capacity, then waits for the
// graph to show it gone. What the refused item's cause does after that is the
// arm's question: the ceiling it named has room, and the writer ceiling that
// now holds it back was never reported.
func releaseAndSettle(sink *graphSink, prov *scripted) error {
prov.releaseOnce()
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
graph, _ := sink.snapshot()
if settledWorkers(graph) > 0 && len(waitingWorkers(graph, agentgraph.WaitSlots)) > 0 {
// Give any second report a chance to arrive before reading.
time.Sleep(500 * time.Millisecond)
return nil
}
time.Sleep(50 * time.Millisecond)
}
graph, _ := sink.snapshot()
return errUnexpected("capacity freed while a refusal still stands", waitStates(graph))
}
func settledWorkers(g agentgraph.Graph) int {
n := 0
for _, node := range g.Nodes {
if node.Kind != agentgraph.KindGroup && node.State != "" && node.State.Terminal() {
n++
}
}
return n
}
// schedulerWaitRows report what a restart inherits about an item the scheduler
// refused. The classification is already right — STARTED settled that — so the
// question here is narrower: can the host still say why it never started?
func schedulerWaitRows(arm string, before, after Observation) []row {
rows := []row{refusalRow(arm, before, after)}
if arm == armWaitTransition {
rows = append(rows, capacityFreedRow(before))
}
return append(rows,
waitSeriesRow(arm, before),
executionKindRow(before, after),
waitProvenanceRow(before, after),
schedulerGateRow(before, after),
)
}
// capacityFreedRow is the transition arm's evidence. It freed the ceiling the
// refusal named and the item stayed queued, held by a different one — so what
// the reported cause does next is a question about the contract, not a race.
func capacityFreedRow(before Observation) row {
return row{
Semantic: "the ceiling the refusal named, once freed", Authority: "the arm, through a child it releases",
Artifact: "none", Reconstruction: "an item settles while the refused one stays queued",
Before: fmt.Sprintf("%d settled, still queued: %s", before.SettledWorkers, orNone(join(before.QueuedWorkers))),
After: "not applicable: the scheduler is gone", Verdict: verdictStable,
}
}
// refusalRow is the arm's own control: it reached the refusal it names, and no
// earlier one. Reaching a different cause means an upper check intercepted it,
// and the arm measured someone else's question.
func refusalRow(arm string, before, after Observation) row {
want := string(wantedCause(arm))
verdict := verdictHolds
if before.WaitCauses[want] == 0 {
verdict = verdictNotMeasured
}
return row{
Semantic: "the refusal this arm reached", Authority: "SubagentScheduler.canStartLocked",
Artifact: "none: reported once through OnQueued", Reconstruction: "slots, then writers, then claim",
Before: causeSummary(before), After: causeSummary(after), Verdict: verdict,
}
}
// waitSeriesRow is what the transition arm exists for: how many times a cause
// was published for the item that never started. Once means the answer is the
// cause that queued it, not the one that held it last.
func waitSeriesRow(arm string, before Observation) row {
return row{
Semantic: "how often a wait cause was reported", Authority: "OnQueued, which fires once per request",
Artifact: "none", Reconstruction: causeContract(before),
Before: seriesSummary(before), After: "not applicable: the scheduler is gone",
Verdict: verdictStable,
}
}
// causeContract states what the observed reporting says the cause means. It is
// read off the series rather than assumed: one report can only be the cause
// that queued the request, and more than one would track the current blocker.
func causeContract(o Observation) string {
for _, seq := range o.WaitSeries {
if len(seq) > 1 {
return "reported again as the blocker changes: the cause is the current one"
}
}
return "reported once: the cause is the one that queued the request, not the one holding it now"
}
// waitProvenanceRow is what the batch was built to expose and then close. The
// refusal exists only at the moment the scheduler makes it, so it is recorded
// there; this compares what the graph showed against what a later process can
// still read back.
func waitProvenanceRow(before, after Observation) row {
return valueRow("why the item never started", "the execution journal's queue records",
"<stem>.execution.jsonl", "the cause the scheduler gave when it first refused admission",
causeSummary(before), journalCauses(after), true)
}
// journalCauses is the refusal a restart reads back, counted the way the graph
// counts it so the two sides of the row are the same measurement.
func journalCauses(o Observation) string {
counts := map[string]int{}
for _, e := range o.Executions {
if e.Queued() {
counts[e.Cause]++
}
}
if len(counts) == 0 {
return ""
}
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k]))
}
return strings.Join(parts, " ")
}
// schedulerGateRow is the other half of what a queue record proves. An item
// only reaches the scheduler once its dependencies are answered, so a queued
// entry is durable evidence it crossed that gate — the one thing a dependency
// edge alone could never say.
func schedulerGateRow(before, after Observation) row {
return valueRow("items proven to have reached the scheduler", "the execution journal's queue records",
"<stem>.execution.jsonl", "a queue record implies the dependency gate was crossed",
queuedIDs(before), queuedIDs(after), true)
}
func queuedIDs(o Observation) string {
var out []string
for _, e := range o.Executions {
if e.Queued() {
out = append(out, e.ID)
}
}
sort.Strings(out)
return join(out)
}
func causeSummary(o Observation) string {
if len(o.WaitCauses) == 0 {
return ""
}
keys := make([]string, 0, len(o.WaitCauses))
for k := range o.WaitCauses {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", k, o.WaitCauses[k]))
}
return strings.Join(parts, " ")
}
func seriesSummary(o Observation) string {
if len(o.WaitSeries) == 0 {
return "none"
}
ids := make([]string, 0, len(o.WaitSeries))
for id := range o.WaitSeries {
ids = append(ids, id)
}
sort.Strings(ids)
out := make([]string, 0, len(ids))
for _, id := range ids {
out = append(out, id+":["+strings.Join(o.WaitSeries[id], "->")+"]")
}
return join(out)
}
// schedulerArmInvalid refuses an arm that did not reach its own refusal. Each
// arm exists to isolate one cause, and one that reached a different cause was
// intercepted by a check above it — reporting that as this arm's answer is how
// a probe measures the wrong ceiling and calls it evidence.
func schedulerArmInvalid(arm string, before Observation) string {
want := string(wantedCause(arm))
if before.WaitCauses[want] == 0 {
return "no item was refused under " + want + "; the arm reached " + orNone(causeSummary(before))
}
for cause := range before.WaitCauses {
if cause != want {
return "an item was also refused under " + cause + ", so this arm does not isolate " + want
}
}
return ""
}
-327
View File
@@ -1,327 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"reasonix/internal/contract/provider"
)
// newScripted builds the provider with its synchronisation channels open. The
// scheduler arms coordinate through them; every other arm never reads them.
func newScripted(arm string) *scripted {
return &scripted{arm: arm, held: make(chan struct{}), release: make(chan struct{})}
}
// probeModelRef is both the catalog ref and the model name boot is given, so
// syntheticEntryFromResolver matches it without touching any config file.
const probeModelRef = "probe/scripted"
// probeAltModelRef is a second identity the same scripted provider answers to.
// Without one, every layer of the model resolution collapses onto the same
// string and a probe cannot tell which layer produced it.
const probeAltModelRef = "probe/alt"
// scripted answers completions from a fixed script so the construct phase can
// establish host state without a network call. A request carrying no tools is a
// host-internal completion (the compaction summary); everything else is a turn.
type scripted struct {
// arm is which run this provider is scripting. Only the fan-out arms read
// it: the pair fleet is the same dispatch either way, and the arm decides
// whether its second item finishes or is still executing at death.
arm string
turnCalls atomic.Int64
// retodo latches the one reply that replaces the task list. The sentinel
// stays in history after the call, so without it every later round would
// rewrite the list again.
retodo atomic.Bool
// asked latches the one reply that opens a question and, in the same round,
// tries to write. Calling ask ends the round, so the write is the effect a
// decision is holding back.
asked atomic.Bool
// Which fleets have been opened. A fleet's aggregate returns into the same
// round that dispatched it, so without a latch the reply that follows reads
// its own sentinel off the still-current user turn and dispatches again.
fleets dispatched
// opened says the arm's own fan-out has gone out, whichever sentinel opened
// it: the terminal and derive arms then hold the turn rather than replying,
// so the process still dies inside one.
opened atomic.Bool
// The scheduler arms dispatch two fleets and must not race them. held is
// closed by the holder's own child, which only runs once its slot is
// granted; release lets the arm free capacity while a refusal stands.
holding sync.Once
released sync.Once
held chan struct{}
release chan struct{}
}
// dispatched is the set of fleets already opened, keyed by the sentinel that
// opened each. One named state rather than a latch per fleet: they share a
// lifetime and a meaning, and every new fan-out was adding another flag.
type dispatched struct {
mu sync.Mutex
seen map[string]bool
}
// held reports whether something already recorded this name, without claiming
// it. The lone-delegation arms read it to ask a negative: that a child the
// scheduler should be holding back never reached the provider at all.
func (d *dispatched) held(name string) bool {
d.mu.Lock()
defer d.mu.Unlock()
return d.seen[name]
}
// first reports whether this is the sentinel's first dispatch, and records it.
func (d *dispatched) first(sentinel string) bool {
d.mu.Lock()
defer d.mu.Unlock()
if d.seen[sentinel] {
return false
}
if d.seen == nil {
d.seen = map[string]bool{}
}
d.seen[sentinel] = true
return true
}
// releaseOnce frees the child waiting on release. Idempotent: an arm may reach
// the moment to release through more than one path.
func (s *scripted) releaseOnce() { s.released.Do(func() { close(s.release) }) }
func (s *scripted) Name() string { return "probe" }
func (s *scripted) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
ch := make(chan provider.Chunk, 8)
go func() {
defer close(ch)
for _, c := range s.script(ctx, req) {
select {
case ch <- c:
case <-ctx.Done():
return
}
}
}()
return ch, nil
}
func (s *scripted) script(ctx context.Context, req provider.Request) []provider.Chunk {
if len(req.Tools) == 0 {
return append(text(summaryBody), done())
}
// A delegated run answers first: the parent's transcript quotes every fleet
// argument, so any later branch that scanned the conversation would make
// the parent answer as one of its own children.
if sentinel := childSentinel(req); sentinel != "" {
return s.childScript(ctx, sentinel)
}
if chunks, ok := s.fanOut(req); ok {
return chunks
}
if s.opens(req, sleepSentinel) {
return append(sleepCall(), done())
}
if s.opensRun(req, taskSentinel) {
if queuedTaskArm(s.arm) {
// The holder's own child says when the ceiling is occupied.
// Dispatching before that lets the delegation win the race and start.
<-s.held
}
return append(loneTaskCall(s.arm), done())
}
if s.opensRun(req, ephemeralSentinel) {
return append(ephemeralCall(s.arm), done())
}
if s.opensRun(req, skillSentinel) {
if queuedSkillArm(s.arm) {
// The holder's own child says when the ceiling is occupied.
// Dispatching before that lets the skill win the race and start.
<-s.held
}
return append(skillCall(s.arm), done())
}
// The one arm whose turn has to close signs the host's steps off first, one
// per round, because a turn that called a tool cannot deliver while the list
// is open. The latch is read last: a round that could not answer keeps it.
if closesItsTurn(s.arm) && s.opened.Load() && hasTool(req.Tools, "complete_step") {
for _, id := range probeStepIDs() {
if s.fleets.first("sign-" + id) {
return append(signStep(id), done())
}
}
}
// A terminal arm needs its fan-out to finish and its turn to stay open: a
// closed turn would settle the marker, and the process would no longer be
// dying inside one.
if (terminalArm(s.arm) || deriveArm(s.arm)) && s.opened.Load() {
<-ctx.Done()
return nil
}
if s.turnCalls.Add(1) == 1 && hasTool(req.Tools, "todo_write") {
return append(todoCall(firstTodos()), done())
}
if hasTool(req.Tools, "todo_write") && requestMentions(req, retodoSentinel) && !s.retodo.Swap(true) {
return append(todoCall(secondTodos()), done())
}
if requestMentions(req, askSentinel) && !s.asked.Swap(true) {
return append(askAndWrite(), done())
}
return append(text(echoMarker(req)+turnBody), done())
}
// A turn is tagged twice: the user side by markerPattern, the assistant reply
// by echoPattern. A fold folds assistant work first, so a one-sided tag would
// let the surviving user turn hide the reply that was dropped beside it.
var (
markerPattern = regexp.MustCompile(`PROBE-MARK-\d+`)
echoPattern = regexp.MustCompile(`PROBE-ECHO-\d+`)
)
func marker(n int) string { return fmt.Sprintf("PROBE-MARK-%03d", n) }
// markersIn returns the tags a message set still shows, in order.
func markersIn(texts []string, pat *regexp.Regexp) []string {
var out []string
for _, t := range texts {
out = append(out, pat.FindAllString(t, -1)...)
}
return out
}
// echoMarker answers a turn with the assistant-side tag for the same number.
func echoMarker(req provider.Request) string {
for _, m := range slices.Backward(req.Messages) {
found := markerPattern.FindAllString(m.Content, -1)
if len(found) == 0 {
continue
}
return "PROBE-ECHO-" + found[len(found)-1][len("PROBE-MARK-"):] + " reply. "
}
return ""
}
func hasTool(schemas []provider.ToolSchema, name string) bool {
for _, t := range schemas {
if t.Name == name {
return true
}
}
return false
}
func text(s string) []provider.Chunk {
return []provider.Chunk{{Type: provider.ChunkText, Text: s}}
}
func done() provider.Chunk { return provider.Chunk{Type: provider.ChunkDone} }
// todoCall writes the list the resume phase looks for. The step ids are the
// point: they are the host identity a completion has to cite. A first write
// may not open with a completed item — the host rejects that shape, and a
// rejected call would leave the row unmeasured rather than answered.
func todoCall(todos []map[string]any) []provider.Chunk {
args, _ := json.Marshal(map[string]any{"todos": todos})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: "probe_todo_" + strconv.Itoa(len(todos)), Name: "todo_write", Arguments: string(args)},
}}
}
// retodoSentinel asks the scripted reply to replace the task list, so a probe
// can move host step identity from one set of ids to another.
const retodoSentinel = "PROBE-RETODO"
// askSentinel asks for a question the host must put to a person, alongside the
// write that question is holding back.
const (
askSentinel = "PROBE-ASK"
deferredEffect = "probe-deferred-effect.txt"
deferredContents = "this write was not held back by the decision"
)
// askAndWrite opens a question and asks to write in the same round. The round
// ends at the question, so the write must not run — not while the answer is
// pending, and not because a later process reloaded the session.
func askAndWrite() []provider.Chunk {
ask, _ := json.Marshal(map[string]any{"questions": []map[string]any{{
"header": "Boundary",
"question": "Which side of the fold should the probe measure?",
"reason": "user_decision",
"options": []map[string]any{
{"label": "Below the fold", "description": "removes folded history"},
{"label": "Above the fold", "description": "leaves coverage intact"},
},
}}})
write, _ := json.Marshal(map[string]string{"path": deferredEffect, "content": deferredContents})
return []provider.Chunk{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "probe_ask_1", Name: "ask", Arguments: string(ask)}},
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "probe_write_1", Name: "write_file", Arguments: string(write)}},
}
}
func requestMentions(req provider.Request, needle string) bool {
for _, m := range slices.Backward(req.Messages) {
if strings.Contains(m.Content, needle) {
return true
}
}
return false
}
func firstTodos() []map[string]any {
return []map[string]any{
{"content": "Establish probe state", "status": "in_progress", "activeForm": "Establishing probe state", "step_id": "probe_step_01"},
{"content": "Cross the process boundary", "status": "pending", "step_id": "probe_step_02"},
{"content": "Report the resurrection matrix", "status": "pending", "step_id": "probe_step_03"},
}
}
// secondTodos replaces the pending work while the in_progress item stays: the
// host refuses to drop an item that is in flight, so this is what a list
// rewrite actually looks like. The two new ids are what a stale note misses.
func secondTodos() []map[string]any {
return []map[string]any{
{"content": "Establish probe state", "status": "in_progress", "activeForm": "Establishing probe state", "step_id": "probe_step_01"},
{"content": "Split the frozen body", "status": "pending", "step_id": "probe_step_21"},
{"content": "Hold the ordering contract", "status": "pending", "step_id": "probe_step_22"},
}
}
// turnBody is fat on purpose: the fold keeps a recent verbatim tail, so thin
// replies leave no region between the pinned head and that tail.
var turnBody = "Probe state established. " + strings.Repeat("The probe records deterministic filler so the fold has material to work on. ", 60)
var summaryBody = strings.Join([]string{
"## Standing facts & constraints",
"The runtime-resume probe is measuring what survives a process boundary.",
"",
"## Goal",
"Establish host state, exit, and observe what a new process can still prove.",
}, "\n")
func resolverFor(prov *scripted) *provider.StaticResolver {
return &provider.StaticResolver{
Descriptors: []provider.Descriptor{{
Ref: probeModelRef, DisplayName: "probe", Model: "scripted",
ContextWindow: 128_000, Tools: true,
}, {
Ref: probeAltModelRef, DisplayName: "probe-alt", Model: "scripted-alt",
ContextWindow: 128_000, Tools: true,
}},
Providers: map[string]provider.Provider{probeModelRef: prov, probeAltModelRef: prov},
}
}
func errUnexpected(what string, got any) error {
return fmt.Errorf("probe could not establish %s (got %v); the arm measures nothing and is reported invalid", what, got)
}
@@ -1,38 +0,0 @@
package main
import (
"strings"
"testing"
)
// Sentinels are matched by substring, so one contained in another dispatches on
// the same prompt: the identity arm silently ran the cancellation fan-out for as
// long as PROBE-PARALLEL sat inside PROBE-PARALLEL-IDENTITY, and its rows read a
// scenario nobody wrote. Care did not catch it — the child sentinels carry a
// comment about exactly this hazard and the parent ones had no guard at all.
func TestNoSentinelContainsAnother(t *testing.T) {
sentinels := map[string]string{
"fleetWarm": fleetWarmSentinel, "fleetMixed": fleetMixedSentinel,
"fleetPair": fleetPairSentinel, "fleetHolder": fleetHolderSentinel,
"fleetRefused": fleetRefusedSentinel, "fleetTerminal": fleetTerminalSentinel,
"fleetOutcomes": fleetOutcomesSentinel, "fleetDerive": fleetDeriveSentinel,
"fleetIdentity": fleetIdentitySentinel, "fleetActive": fleetActiveSentinel,
"fleetSettled": fleetSettledSentinel, "fleetLive": fleetLiveSentinel,
"parallel": parallelSentinel, "parallelIdentity": parallelIdentity,
"task": taskSentinel, "sleep": sleepSentinel,
"ask": askSentinel, "retodo": retodoSentinel,
"childDone": childDone, "childHang": childHang, "childFail": childFail,
"childHold": childHold, "childRelease": childRelease, "childFailLate": childFailLate,
}
for outerName, outer := range sentinels {
for innerName, inner := range sentinels {
if outerName == innerName {
continue
}
if strings.Contains(outer, inner) {
t.Errorf("%s (%q) contains %s (%q): a prompt naming the first dispatches the second too",
outerName, outer, innerName, inner)
}
}
}
}
-694
View File
@@ -1,694 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reasonix/internal/state/sessionstore"
"sort"
"strings"
"time"
"reasonix/internal/contract/provider"
"reasonix/internal/ext/skill"
"reasonix/internal/session/control"
)
// The skill-runner classification arms. They ask what kind of execution a
// runAs=subagent skill is, because the answer decides whether it belongs to the
// delegation class the task and fan-out arms established or is an execution
// class of its own. Nothing here is a fix: a classification that guessed would
// be answered by whichever plumbing happened to exist.
const (
// armSkillCompleted lets a skill finish and the turn close, then asks what
// each layer kept. It runs both entry points — the tool a model calls and
// the controller's own — because their lineage differs by design and a row
// that read one for the other would report intent as a defect.
armSkillCompleted = "skill-completed"
// armSkillRunning kills the process with the child mid-execution: the death
// every other delegation arm takes.
armSkillRunning = "skill-running"
// armSkillQueued fills the ceiling first. It is the arm that says whether a
// skill belongs to the same admission domain as everything else, which is a
// different question from whether it leaves the same records.
armSkillQueued = "skill-queued"
// armSkillCancel stops a running skill through the turn that admitted it, so
// the stop has the owner a person's Stop has.
armSkillCancel = "skill-cancel"
// The read-only entry point is a separate surface, not a lighter version of
// the one above: it prepares no transcript and refuses continuation, so
// asking it for durability would be demanding it break its own contract.
// These three ask what it is instead.
armReadOnlySkillCompleted = "readonly-skill-completed"
armReadOnlySkillRunning = "readonly-skill-running"
armReadOnlySkillQueued = "readonly-skill-queued"
)
func skillArm(name string) bool {
switch name {
case armSkillCompleted, armSkillRunning, armSkillQueued, armSkillCancel:
return true
}
return readOnlySkillArm(name)
}
func readOnlySkillArm(name string) bool {
switch name {
case armReadOnlySkillCompleted, armReadOnlySkillRunning, armReadOnlySkillQueued:
return true
}
return false
}
func queuedSkillArm(name string) bool {
return name == armSkillQueued || name == armReadOnlySkillQueued
}
// runningSkillArm names the arms that die or are stopped with the child inside
// the provider, which is the only state a durable record could be missing from.
func runningSkillArm(name string) bool {
return name == armSkillRunning || name == armSkillCancel || name == armReadOnlySkillRunning
}
const (
skillSentinel = "PROBE-SKILL"
// skillCallID is the tool call the model makes. It is the identity the
// store would join to, so the arm names it rather than letting the round
// pick one.
skillCallID = "probe_skill"
// probeSkillName is the skill on disk. Not a built-in: those carry their own
// read-only promises and review contracts, and the arm needs a plain writer
// so the scheduler sees a claim rather than a reader.
probeSkillName = "probe-worker"
// hostSkillTask is what the controller's own entry point is asked to do. It
// completes, because that arm compares two lineages rather than two deaths.
hostSkillTask = childDone + " host-initiated skill"
)
// writeProbeSkill puts a runAs=subagent skill in the workspace, where a project
// skill lives. It is a writer: a read-only one would clear the writer and claim
// checks by not being subject to them, and the arm could not say whether a skill
// is admitted the way every other writer is.
func writeProbeSkill(workspace string) error {
dir := filepath.Join(workspace, ".reasonix", skill.SkillsDirname, probeSkillName)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
body := "---\nname: " + probeSkillName + "\n" +
"description: The probe's own subagent, which answers whatever task it is handed.\n" +
"runAs: subagent\n---\n\nAnswer the task you are given and stop.\n"
return os.WriteFile(filepath.Join(dir, skill.SkillFile), []byte(body), 0o644)
}
// skillCall is the dispatch under measurement, made through the tool a model
// calls. The read-only arms enter through the other tool, which is a different
// runner rather than a flag on this one.
func skillCall(arm string) []provider.Chunk {
name := "run_skill"
if readOnlySkillArm(arm) {
name = "read_only_skill"
}
task := skillChildSentinel(arm) + " skill body"
args, _ := json.Marshal(map[string]any{"name": probeSkillName, "arguments": task})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: skillCallID, Name: name, Arguments: string(args)},
}}
}
// skillChildSentinel is how this arm's child behaves. The completed arms need one
// that finishes; every other arm needs one that holds until the process dies.
func skillChildSentinel(arm string) string {
if arm == armSkillCompleted || arm == armReadOnlySkillCompleted {
return childDone
}
return childHang
}
// skillSentinels is what this arm's one prompt carries. The queued arms name the
// holder as well: its ceiling has to be occupied before the skill asks for a
// slot, or the refusal they are built to record never happens.
func skillSentinels(arm string) string {
if queuedSkillArm(arm) {
return fleetHolderSentinel + " " + skillSentinel
}
return skillSentinel
}
// runSkillClassConstruct drives one skill and dies where this arm is about. The
// completed arms let the turn close and then run the controller's own entry
// point, so one death holds both lineages.
func runSkillClassConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
prompt := fanOutTurn(turn+1, skillSentinels(arm))
completed := arm == armSkillCompleted || arm == armReadOnlySkillCompleted
switch {
case completed:
if err := ctrl.Run(ctx, prompt); err != nil {
return fmt.Errorf("skill turn: %w", err)
}
if err := runHostInitiatedSkill(ctx, ctrl, arm); err != nil {
return err
}
case arm == armSkillCancel:
// A stop belongs to whoever admitted the turn, so the arm that is going
// to be stopped is admitted the way a person's input is.
ctrl.Send(prompt)
if err := waitForSkill(sink, prov, arm); err != nil {
return err
}
default:
go func() { _ = ctrl.Run(ctx, prompt) }()
if err := waitForSkill(sink, prov, arm); err != nil {
return err
}
}
stop := ""
if arm == armSkillCancel {
stop = stopSkill(ctrl, prov, arm)
}
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
child := skillChildSentinel(arm)
obs.Progress[probeChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(child)))}
obs.Progress[probeSkillChildLeft] = []string{fmt.Sprint(prov.fleets.held(childLeft(child)))}
obs.Progress[probeSkillChildCtxDone] = []string{fmt.Sprint(prov.fleets.held(childCtxDone(child)))}
obs.Progress[probeSkillStanding] = []string{skillStanding(sink, prov)}
if stop != "" {
obs.Progress[probeStopReached] = []string{stop}
}
if err := writeObservation(root, obs); err != nil {
return err
}
os.Exit(0)
return nil
}
// runHostInitiatedSkill runs the same skill through the controller's own entry
// point, which is what a slash invocation reaches. Its lineage is deliberately
// different — the runner drops the call id for a host-initiated run — so the
// identity row has both to compare rather than one to judge.
func runHostInitiatedSkill(ctx context.Context, ctrl *control.Controller, arm string) error {
if _, err := ctrl.RunSubagentProfile(ctx, probeSkillName, hostSkillTask, readOnlySkillArm(arm)); err != nil {
return fmt.Errorf("host-initiated skill: %w", err)
}
return nil
}
// The records a skill's child leaves. They ride the observation because only the
// scripted provider knows them, and a store row read without them would be
// asking about work nothing proves ran.
const (
probeSkillChildLeft = "probe:skill-child-left"
probeSkillChildCtxDone = "probe:skill-child-ctxdone"
// probeSkillStanding is where the call itself stood: dispatched, answered,
// and whether the ceiling was occupied. A refusal has no event of its own,
// so this is the only positive evidence the admission row has.
probeSkillStanding = "probe:skill-standing"
)
// waitForSkill blocks until the skill stands where this arm dies. A refusal is a
// state nothing announces, so the queued arms establish it from both sides — the
// call went out and has not answered, the ceiling is occupied, and the skill's
// own child never arrived — then hold still and ask again, because a child that
// was merely slow to start would have reached the provider by then.
func waitForSkill(sink *graphSink, prov *scripted, arm string) error {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
if !queuedSkillArm(arm) {
if prov.fleets.held(childEntered(childHang)) {
return nil
}
} else if skillHeldBack(sink, prov) {
time.Sleep(2 * time.Second)
if !skillHeldBack(sink, prov) {
return errUnexpected("a skill the scheduler was still holding back",
"its child ran or its call answered")
}
return nil
}
time.Sleep(50 * time.Millisecond)
}
return errUnexpected("a skill child "+skillWanted(arm), skillStanding(sink, prov))
}
// skillHeldBack is the refusal expressed as facts rather than as an absence: the
// ceiling is occupied, the call is out, it has not answered, and no child of its
// own has reached the provider.
func skillHeldBack(sink *graphSink, prov *scripted) bool {
_, dispatched := sink.toolDispatched(skillCallID)
_, answered := sink.toolResult(skillCallID)
return prov.fleets.held(childEntered(childHold)) && dispatched && !answered &&
!prov.fleets.held(childEntered(childHang))
}
func skillStanding(sink *graphSink, prov *scripted) string {
_, dispatched := sink.toolDispatched(skillCallID)
_, answered := sink.toolResult(skillCallID)
return fmt.Sprintf("ceiling=%t dispatched=%t answered=%t child=%t",
prov.fleets.held(childEntered(childHold)), dispatched, answered,
prov.fleets.held(childEntered(childHang)))
}
func skillWanted(arm string) string {
if queuedSkillArm(arm) {
return "held back by a full ceiling"
}
return "inside the provider"
}
// stopSkill stops the work the way a person does — the turn's own cancel — and
// waits to see what that reaches. Nothing is demanded: whether a stop arrives is
// what the arm measures, and an arm that failed when it did not would report its
// own finding as a broken premise.
func stopSkill(ctrl *control.Controller, prov *scripted, arm string) string {
stopped := time.Now()
ctrl.Cancel()
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) && ctrl.Running() {
time.Sleep(50 * time.Millisecond)
}
live := "gone"
if prov.fleets.held(childEntered(childHang)) && !prov.fleets.held(childLeft(childHang)) {
live = "still running"
}
return fmt.Sprintf("turn=%s work=%s ctx=%t after=%s", turnStanding(ctrl, arm), live,
prov.fleets.held(childCtxDone(childHang)), time.Since(stopped).Round(time.Second))
}
// skillClassRows are the seven dimensions, one row each. They are separate
// because a skill can share one domain and not another — the whole reason this
// is a classification and not a comparison.
func skillClassRows(arm string, before, after Observation) []row {
return []row{
skillAdmissionRow(arm, before),
skillGraphRow(before),
skillJournalRow(before, after),
skillStoreRow(before, after),
skillCancelRow(arm, before),
skillRestartRow(arm, before, after),
skillIdentityRow(arm, before, after),
skillClassVerdictRow(arm, before, after),
}
}
const skillAuthority = "the scheduler, read through what the child did"
// skillAdmissionRow is the scheduler dimension. There is no refusal hook on a
// skill's acquire, so the refusal is read the only way it can be: the ceiling was
// proven occupied and the child never arrived, against the sibling arm where the
// ceiling was free and it did.
func skillAdmissionRow(arm string, before Observation) row {
entered := firstProgress(before, probeChildEntered) == "true"
answer := "admitted: the child reached the provider"
verdict := verdictHolds
switch {
case queuedSkillArm(arm) && !entered:
answer = "refused: the ceiling was full and the child never arrived"
case queuedSkillArm(arm):
answer = "not held back: the child ran with the ceiling full"
verdict = verdictViolated
case !entered:
answer = "the child never arrived with capacity free"
verdict = verdictNotMeasured
}
return row{
Semantic: "does it ask the session scheduler", Authority: skillAuthority,
Artifact: "none observed", Reconstruction: "in-process only",
Before: answer + " [" + firstProgress(before, probeSkillStanding) + "]",
After: "—", Verdict: verdict,
}
}
// skillGraphRow is the live picture. A skill that never draws one is invisible
// to every frontend while it runs, which is a product fact rather than a
// durability one and is why it is its own row.
func skillGraphRow(before Observation) row {
var drawn []string
for _, n := range before.Graph.Nodes {
if strings.HasPrefix(n.ID, skillCallID) {
drawn = append(drawn, n.ID+":"+string(n.State))
}
}
sort.Strings(drawn)
// The whole graph's size rides along: "the filter found nothing" and "there
// was nothing to find" are different readings, and only the second is what
// this row means to report.
return row{
Semantic: "does the live graph hold it", Authority: liveAuthority,
Artifact: "none observed", Reconstruction: "in-process only",
Before: fmt.Sprintf("%s (graph held %d node(s))", orNone(join(drawn)), len(before.Graph.Nodes)),
After: "—", Verdict: presenceVerdict(len(drawn) > 0),
}
}
// presenceVerdict reports what was found without calling absence a defect. What
// a skill owes each layer is the question; a row that answered it here would be
// asserting the classification it exists to establish.
func presenceVerdict(present bool) string {
if present {
return verdictHolds
}
return verdictAbsent
}
const verdictAbsent = "absent"
// skillJournalRow reports what the journal holds. It does not go through the
// ordinary value comparison: two empty columns are equal, and calling that
// persisted would report an execution nothing recorded as durably recorded —
// which is the substitution this whole probe exists to refuse.
func skillJournalRow(before, after Observation) row {
return presenceRow("does the journal record it", journalAuthority, "<stem>.execution.jsonl",
"ExecutionHistory over the whole session", skillExecutions(before), skillExecutions(after))
}
// presenceRow answers "is anything here" before it answers "is it the same".
// Absence on both sides is absence, never agreement.
func presenceRow(semantic, authority, artifact, reconstruction string, before, after []string) row {
r := row{Semantic: semantic, Authority: authority, Artifact: artifact,
Reconstruction: reconstruction, Before: orNone(join(before)), After: orNone(join(after))}
switch {
case len(before) == 0 && len(after) == 0:
r.Verdict = verdictAbsent
case len(after) == 0:
r.Verdict = verdictLost
case r.Before == r.After:
r.Verdict = verdictPersisted
default:
r.Verdict = verdictLossy
}
return r
}
// skillExecutions are the journal entries this arm's skill could be recorded
// under. The whole journal is read rather than a prefix: a skill records nothing
// today, so a filter written around an id it never wrote would report absence by
// construction.
func skillExecutions(o Observation) []string {
var out []string
for _, e := range o.Executions {
if strings.HasPrefix(e.ID, skillCallID) || strings.Contains(e.Name, probeSkillName) {
out = append(out, e.ID)
}
}
sort.Strings(out)
return out
}
// skillStoreRow is the durable half. The two entry points differ here by design:
// one prepares a transcript, the other refuses to.
func skillStoreRow(before, after Observation) row {
return presenceRow("what the store holds for it", storeAuthority, "subagents/<ref>.meta.json",
"ListSubagentsByParent over the transcript stem",
skillChildren(before), skillChildren(after))
}
// skillChildren are the store's records of skill children, by the kind the
// runner declared rather than by a name match: kind is what the store was told
// this execution is.
func skillChildren(o Observation) []string {
var out []string
for _, f := range o.Children.Facts {
if f.Kind == "skill" {
out = append(out, f.Name+"/"+orDash(f.ParentToolCallID)+"="+f.Status)
}
}
sort.Strings(out)
return out
}
// skillCancelRow is what a stop reached. Three facts, kept apart: a context that
// closed and a call that returned are different events, and work whose caller
// stopped waiting leaves the same absence behind as work that was cancelled.
func skillCancelRow(arm string, before Observation) row {
if arm != armSkillCancel {
return row{
Semantic: "what a stop reaches", Authority: "the turn's own cancel",
Artifact: "none observed", Reconstruction: "in-process only",
Before: "not stopped in this arm", After: "—", Verdict: verdictNotMeasured,
}
}
return row{
Semantic: "what a stop reaches", Authority: "the turn's own cancel",
Artifact: "subagents/<ref>.meta.json", Reconstruction: "read at one instant, after the record closed",
Before: fmt.Sprintf("%s store=%s", firstProgress(before, probeStopReached),
orNone(join(skillChildren(before)))),
After: "—", Verdict: skillCancelVerdict(before),
}
}
// skillCancelVerdict asks whether the terminal the store kept is the one that
// happened. The cause is observed, not read off a message: the child's own
// context closed, which is what a cancellation does and what a failure does not,
// so a record that files it as failed is naming the wrong ending.
func skillCancelVerdict(before Observation) string {
cancelled := firstProgress(before, probeSkillChildCtxDone) == "true"
terminal := ""
for _, f := range before.Children.Facts {
if f.Kind == "skill" {
terminal = f.Status
}
}
switch {
case terminal == "":
return verdictNotMeasured
case cancelled && terminal != string(sessionstore.SubagentCancelled):
return verdictViolated
default:
return verdictHolds
}
}
// skillRestartRow is what the next process can say. It is separate from the
// store row because "a record survives" and "the execution is explicable" are
// different claims: a completed child on disk explains nothing about a run that
// was cut.
func skillRestartRow(arm string, before, after Observation) row {
stored, rebuilt, cut := skillChildren(after), skillRebuiltNodes(after), skillInterruptions(after)
answer := fmt.Sprintf("store=%s rebuilt=%s cut=%s",
orNone(join(stored)), orNone(join(rebuilt)), orNone(join(cut)))
return row{
Semantic: "what a restart can say about it", Authority: rebuildAuthority,
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
Reconstruction: "execgraph.Rebuild over the journal, and the store beside it",
Before: skillAtDeath(arm, before), After: answer,
Verdict: skillRestartVerdict(stored, rebuilt, cut),
}
}
// skillInterruptions are the interruptions that name this skill. The whole list
// is wider than the arm: a queued arm fills its ceiling with a fan-out, and a
// fan-out records everything — reading its interruption as the skill's is how an
// arm reports a gap as closed by evidence belonging to something else.
func skillInterruptions(o Observation) []string {
var out []string
for _, e := range o.InterruptedExecutions {
if strings.HasPrefix(e.ID, skillCallID) || strings.Contains(e.Name, probeSkillName) {
out = append(out, e.ID)
}
}
sort.Strings(out)
return out
}
// skillRestartVerdict keeps two claims apart. That the store remembers a child
// finished is not that a restart can reconstruct the delegated execution: the
// first is a record, the second is provenance, and an arm that folded them would
// report a run nothing can place as recovered.
func skillRestartVerdict(stored, rebuilt, cut []string) string {
switch {
case len(rebuilt) > 0 || len(cut) > 0:
return verdictHolds
case len(stored) > 0:
return verdictRecordOnly
default:
return verdictAbsent
}
}
// verdictRecordOnly is a child the store kept for an execution no reconstruction
// names.
const verdictRecordOnly = "record-only"
// skillRebuiltNodes are the nodes a restart's rebuild draws for this skill.
func skillRebuiltNodes(o Observation) []string {
var out []string
for _, n := range rebuiltGraph(o).Graph.Nodes {
if strings.HasPrefix(n.ID, skillCallID) || strings.Contains(n.Label, probeSkillName) {
out = append(out, n.ID+":"+string(n.State))
}
}
sort.Strings(out)
return out
}
func skillAtDeath(arm string, before Observation) string {
switch {
case queuedSkillArm(arm):
return "held back, never started"
case firstProgress(before, probeChildEntered) != "true":
return "never reached the provider"
case skillFinished(before):
return "ran and returned"
default:
return "executing inside the provider"
}
}
// skillIdentityRow is the seventh dimension, and it reads the two entry points
// apart. A model-invoked skill has a call to join to; the controller's own entry
// point drops that id on purpose, so an empty one there is top-level provenance
// rather than a lost join — reading them together is what stops a correct value
// being reported as a defect.
func skillIdentityRow(arm string, before, after Observation) row {
var got []string
for _, f := range after.Children.Facts {
if f.Kind != "skill" {
continue
}
got = append(got, fmt.Sprintf("%s from=%s model=%s effort=%s",
f.Name, orDash(f.ParentToolCallID), orDash(f.Model), orDash(f.Effort)))
}
sort.Strings(got)
return row{
Semantic: "what identity the record carries", Authority: storeAuthority,
Artifact: "subagents/<ref>.meta.json",
Reconstruction: "the call the model made, against the entry point that made it",
Before: skillLineageExpected(arm), After: orNone(join(got)),
Verdict: skillIdentityVerdict(after),
}
}
func skillLineageExpected(arm string) string {
if arm == armSkillCompleted || arm == armReadOnlySkillCompleted {
return "model-invoked joins the execution under " + skillCallID + "; host-initiated names none"
}
return "model-invoked joins the execution opened under " + skillCallID
}
// skillIdentityVerdict holds when the model-invoked record joins the execution
// the journal opened under the call the model made. The join is checked, never
// one spelling of it: a record's parent is the execution's own identity, so
// comparing it to the bare call id would fail the chain that makes them
// joinable. A host-started run has no call to hang under, so it is not held.
func skillIdentityVerdict(after Observation) string {
opened := map[string]bool{}
for _, id := range skillExecutions(after) {
opened[id] = true
}
joined, records := false, 0
for _, f := range after.Children.Facts {
if f.Kind != "skill" {
continue
}
records++
if opened[f.ParentToolCallID] && strings.HasPrefix(f.ParentToolCallID, skillCallID) {
joined = true
}
}
switch {
case records == 0:
return verdictNotMeasured
case joined:
return verdictHolds
default:
return verdictViolated
}
}
// skillClassVerdictRow is the arm's answer in one line, and it is a conjunction
// rather than a comparison: an execution the scheduler admitted, whose child
// reached the provider, that no live picture, no journal and no active store
// record names, is lost while it is happening — not merely unrecorded.
func skillClassVerdictRow(arm string, before, after Observation) row {
entered := firstProgress(before, probeChildEntered) == "true"
graph := len(skillGraphNodes(before)) > 0
journal := len(skillExecutions(before)) > 0
stored := len(skillChildren(before)) > 0
answer, verdict := "not this arm's question", verdictNotMeasured
switch {
case queuedSkillArm(arm):
answer, verdict = skillQueuedNegative(before)
case readOnlySkillArm(arm) && runningSkillArm(arm) && entered:
answer, verdict = readOnlyClass(graph, journal, stored)
case runningSkillArm(arm) && entered:
if graph || journal || stored {
answer, verdict = fmt.Sprintf("named by graph=%t journal=%t store=%t", graph, journal, stored), verdictHolds
} else {
answer, verdict = "LOST-SILENT: executing, and named by no layer", verdictViolated
}
case runningSkillArm(arm):
answer = "the child never reached the provider"
}
return row{
Semantic: "an execution in flight, named by nothing", Authority: crossAuthority,
Artifact: "none, which is the finding",
Reconstruction: "scheduler admitted + child entered + no graph, journal or store record",
Before: answer, After: "—", Verdict: verdict,
}
}
// readOnlyClass classifies the entry point that promised no durable side
// effects. Absence there is its contract kept, not a gap — but it still holds a
// scheduler slot, so whether anything draws it while it does is a separate
// answer, and a durable record would be the contract broken the other way.
func readOnlyClass(graph, journal, stored bool) (string, string) {
switch {
case journal || stored:
return "an entry point that promised no durable side effects left one", verdictViolated
case graph:
return "ephemeral by contract, and drawn while it holds a slot", verdictEphemeralSeen
default:
return "ephemeral by contract, and unseen while it holds a slot", verdictEphemeralUnseen
}
}
// The two readings of an execution that keeps nothing. Neither is a defect: what
// separates them is whether anyone can see the slot being held.
const (
verdictEphemeralSeen = "ephemeral-seen"
verdictEphemeralUnseen = "ephemeral-unseen"
)
// skillQueuedNegative is the control the running row needs, and it is about the
// store alone. An opening is the orchestration saying work entered it, which a
// refused item did; a store record is a child execution artifact, which it has
// none of. Demanding silence from the journal too would forbid the very record
// that tells a restart the item was held back rather than lost.
func skillQueuedNegative(before Observation) (string, string) {
if len(skillChildren(before)) > 0 {
return "a child record for work the scheduler never admitted", verdictViolated
}
return "held back: opened and queued, with no child execution artifact", verdictHolds
}
func skillGraphNodes(o Observation) []string {
var out []string
for _, n := range o.Graph.Nodes {
if strings.HasPrefix(n.ID, skillCallID) {
out = append(out, n.ID)
}
}
return out
}
// skillArmInvalid reports the premise this arm could not establish. A skill that
// never reached the provider measures nothing about executions in flight, and a
// ceiling that was not occupied measures nothing about admission.
func skillArmInvalid(arm string, before Observation) string {
entered := firstProgress(before, probeChildEntered) == "true"
switch {
case queuedSkillArm(arm) && entered:
return "the skill ran while the ceiling was meant to be full"
case runningSkillArm(arm) && !entered:
return "the skill's child never reached the provider, so nothing was executing at the death"
case (arm == armSkillCompleted || arm == armReadOnlySkillCompleted) && !skillFinished(before):
return "no skill child finished, so the arm compares nothing"
}
return ""
}
func skillFinished(before Observation) bool {
return firstProgress(before, probeSkillChildLeft) == "true"
}
-69
View File
@@ -1,69 +0,0 @@
package main
import "fmt"
// successorRows ask what happens to a recorded interruption once work carries
// on. The derivation has no way out of "interrupted" today, so these rows are
// there to say what that costs rather than to grade it.
func successorRows(arm string, successor, final Observation) []row {
return []row{{
Semantic: "the interruption after work continued", Authority: "the adjudication log",
Artifact: "<stem>.adjudication.jsonl", Reconstruction: "still derived from an open record with no owner",
Before: interruptionState(successor), After: interruptionState(final), Verdict: stickiness(final),
}, {
Semantic: "what that turn's request carried", Authority: "control, projected per request",
Artifact: "none: derived, never stored", Reconstruction: "the interruption block, if one still stands",
Before: fmt.Sprintf("%d block(s)", successor.ModelSeesInterruption),
After: fmt.Sprintf("%d block(s)", final.ModelSeesInterruption), Verdict: verdictStable,
}, {
Semantic: "how the barrier ended", Authority: "the adjudication log",
Artifact: "<stem>.adjudication.jsonl", Reconstruction: "an edge is written only for a barrier a turn received",
Before: journalSummary(successor), After: journalSummary(final), Verdict: verdictStable,
}, {
Semantic: "the effect the dead decision held back", Authority: "the workspace",
Artifact: final.Deferred.MarkerPath, Reconstruction: "no successor turn may release it",
Before: ranOrNot(successor), After: ranOrNot(final), Verdict: heldBack(successor, final),
}}
}
func interruptionState(o Observation) string {
if len(o.Interrupted) == 0 {
return "settled"
}
return fmt.Sprintf("%d still interrupted", len(o.Interrupted))
}
// stickiness is the question these arms were built to ask: does an
// interruption outlive the work that replaced it? A successor that took it over
// leaves nothing behind for later requests to carry.
func stickiness(final Observation) string {
if len(final.Interrupted) == 0 && final.ModelSeesInterruption == 0 {
return "inherited"
}
if len(final.Interrupted) == 0 {
return "sticky context without an owner"
}
return "sticky"
}
func journalSummary(o Observation) string {
var out []string
for _, e := range o.Journal {
switch {
case e.Open():
out = append(out, e.ID+":open")
case e.SupersededBy != "":
out = append(out, e.ID+":"+e.Disposition+" by turn "+e.SupersededBy)
default:
out = append(out, e.ID+":"+e.Disposition)
}
}
return orNone(join(out))
}
func heldBack(successor, final Observation) string {
if successor.Deferred.Executed || final.Deferred.Executed {
return verdictViolated
}
return verdictHolds
}
-533
View File
@@ -1,533 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"sort"
"strings"
"time"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/session/control"
)
// The terminal-disposition arms. SETTLED says an opening is no longer active
// and nothing more, so these ask what that costs: two items can share every
// durable fact and mean opposite things, and a restart that cannot tell them
// apart has lost the answer a dependent needs, not a label a picture wanted.
const (
// armTerminalAdopted reuses an earlier child's answer. It never runs and
// still holds a final answer, which is the one terminal that is answered
// without having executed.
armTerminalAdopted = "terminal-adopted"
// armTerminalSkippedDep is the same lifecycle with the opposite meaning: an
// item whose dependency failed, which never ran and holds no answer.
armTerminalSkippedDep = "terminal-skipped-dep"
// armTerminalCancelled is the other way an item ends without an answer. It
// was built to reach parallel_tasks' skipped branch and found it
// unreachable: running[i] is set when an item is dispatched, not when it is
// admitted, so a cancellation marks every unfinished item cancelled.
armTerminalCancelled = "terminal-cancelled"
// armTerminalContext asks whether a context edge has to be recorded at all,
// or already follows from the ordering and what the upstream answered.
armTerminalContext = "terminal-context"
// armChildTerminal compares the graph's terminal for an executed item
// against what the sub-agent store recorded for the same child. The store
// is the owner of "what happened to this child", so a distinction the graph
// draws and the store does not keep is one no reconstruction can recover.
armChildTerminal = "child-terminal"
// armIdentitySemantics asks what a node's model and effort mean. Two
// producers resolve them through different chains and the store resolves
// them again, so before a rebuild can be held to them, one of those has to
// be the fact the field carries.
armIdentitySemantics = "identity-semantics"
)
func terminalArm(name string) bool {
switch name {
case armTerminalAdopted, armTerminalSkippedDep, armTerminalCancelled, armTerminalContext, armChildTerminal, armIdentitySemantics:
return true
}
return false
}
// terminalSentinels is what this arm's one prompt carries. The adopted arm
// needs a completed reference first, so it names the warm-up fleet too.
func terminalSentinels(arm string) string {
switch arm {
case armTerminalAdopted:
return fleetWarmSentinel + " " + fleetMixedSentinel
case armTerminalCancelled:
return parallelSentinel
case armChildTerminal:
return fleetOutcomesSentinel
case armIdentitySemantics:
return fleetWarmSentinel + " " + fleetIdentitySentinel + " " + parallelIdentity
}
return fleetTerminalSentinel
}
// runTerminalConstruct lets the fan-out settle, then dies before the turn ends.
// The settling is the point: a fan-out publishes an item's skip only in its
// closing delta, so an arm that died mid-flight would never see one.
func runTerminalConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, turn int) error {
runCtx, cancelRun := context.WithCancel(ctx)
defer cancelRun()
go func() { _ = ctrl.Run(runCtx, fanOutTurn(turn+1, terminalSentinels(arm))) }()
if arm == armTerminalCancelled {
if err := cancelBeforeStart(sink, cancelRun); err != nil {
return err
}
}
if arm == armChildTerminal {
if err := cancelAfterOutcomes(sink, cancelRun); err != nil {
return err
}
}
if err := waitForTerminal(sink, arm); err != nil {
return err
}
if err := writeObservation(root, capture("construct", arm, bootSystem, ctrl, sink, root)); err != nil {
return err
}
os.Exit(0)
return nil
}
// cancelBeforeStart interrupts the group once one item is running and another
// is still waiting for a slot, which is the state parallel_tasks splits on. It
// cancels the turn's own context rather than calling Controller.Cancel: the
// synchronous headless Run does not pass through turn admission, so the gate
// that Cancel operates has nothing registered for it.
func cancelBeforeStart(sink *graphSink, cancelRun context.CancelFunc) error {
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
graph, _ := sink.snapshot()
if runningCount(graph) > 0 && len(queuedWorkers(graph)) > 0 {
cancelRun()
return nil
}
time.Sleep(50 * time.Millisecond)
}
graph, _ := sink.snapshot()
return errUnexpected("one item running and one still queued", waitStates(graph))
}
// cancelAfterOutcomes interrupts once one item has completed and another has
// failed, with a third still running. All three terminals then come from one
// group, so the store's record of them is compared under identical conditions.
func cancelAfterOutcomes(sink *graphSink, cancelRun context.CancelFunc) error {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
graph, _ := sink.snapshot()
if len(nodesInState(graph, agentgraph.StateCompleted)) > 0 &&
len(nodesInState(graph, agentgraph.StateFailed)) > 0 &&
runningCount(graph) > 0 {
cancelRun()
return nil
}
time.Sleep(50 * time.Millisecond)
}
graph, _ := sink.snapshot()
return errUnexpected("one completed, one failed and one still running", waitStates(graph))
}
func runningCount(g agentgraph.Graph) int {
n := 0
for _, node := range g.Nodes {
if node.Kind != agentgraph.KindGroup && node.State == agentgraph.StateRunning {
n++
}
}
return n
}
// wantedTerminal is the disposition this arm exists to produce.
func wantedTerminal(arm string) agentgraph.NodeState {
if arm == armTerminalAdopted {
return agentgraph.StateAdopted
}
if arm == armTerminalContext {
return agentgraph.StateCompleted
}
if arm == armTerminalCancelled || arm == armChildTerminal {
return agentgraph.StateCancelled
}
if arm == armIdentitySemantics {
return agentgraph.StateAdopted
}
return agentgraph.StateSkipped
}
// waitForTerminal blocks until the graph shows this arm's disposition. The
// identity arm needs more than a state: it compares three producers, so it
// waits until the last of them has settled something rather than the first.
func waitForTerminal(sink *graphSink, arm string) error {
want := wantedTerminal(arm)
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
graph, _ := sink.snapshot()
if len(nodesInState(graph, want)) > 0 && identityReady(graph, arm) {
return nil
}
time.Sleep(50 * time.Millisecond)
}
graph, _ := sink.snapshot()
return errUnexpected("an item in state "+string(want), waitStates(graph))
}
// identityReady reports whether every producer the identity arm compares has
// published a settled item. Waiting on one of them leaves the others unread.
func identityReady(g agentgraph.Graph, arm string) bool {
if arm != armIdentitySemantics {
return true
}
settled := map[string]int{}
for _, n := range g.Nodes {
if n.Kind == agentgraph.KindGroup || n.State == "" || !n.State.Terminal() {
continue
}
settled[n.ParentID]++
}
return settled["probe_fleet_identity"] >= 4 && settled["probe_parallel_identity"] >= 2
}
func nodesInState(g agentgraph.Graph, want agentgraph.NodeState) []string {
var out []string
for _, n := range g.Nodes {
if n.Kind != agentgraph.KindGroup && n.State == want {
out = append(out, n.ID)
}
}
sort.Strings(out)
return out
}
// terminalRows put the two lifecycles side by side. Adopted and skipped reach
// the same durable shape by different routes and mean opposite things to a
// dependent, so every row here compares what the graph knew against what the
// journal can still be asked.
func terminalRows(arm string, before, after Observation) []row {
rows := []row{
terminalStateRow(arm, before, after),
terminalLifecycleRow(before, after),
terminalAnswerRow(before, after),
}
if arm == armTerminalAdopted {
rows = append(rows, adoptSourceRow(before, after))
}
if arm == armTerminalContext || arm == armTerminalSkippedDep {
rows = append(rows, contextDerivabilityRow(before, after))
}
if arm == armChildTerminal {
rows = append(rows, childOutcomeRow(before, after))
}
if arm == armIdentitySemantics {
rows = append(rows, identitySemanticsRow(before), identityRebuildRow(before, after))
}
return rows
}
// terminalStateRow is the arm's own control and the loss in one line: which
// disposition the graph ended on, and whether anything still says so.
func terminalStateRow(arm string, before, after Observation) row {
want := wantedTerminal(arm)
verdict := verdictNotMeasured
if len(nodesInState(graphOf(before), want)) > 0 {
verdict = verdictHolds
}
return row{
Semantic: "the disposition this arm reached", Authority: "the fan-out's closing delta",
Artifact: "none observed", Reconstruction: "published once, when the group ends",
Before: terminalSummary(before), After: terminalSummary(after), Verdict: verdict,
}
}
// terminalLifecycleRow is the comparison that decides whether a disposition has
// to be recorded. Two items with the same durable lifecycle and opposite
// meanings cannot be told apart by anything a restart reads.
func terminalLifecycleRow(before, after Observation) row {
return valueRow("the durable lifecycle each item left", "the execution journal",
"<stem>.execution.jsonl", "opened / queued / started / settled, per item",
lifecycleSummary(before), lifecycleSummary(after), true)
}
// terminalAnswerRow is what a dependent actually needs. Completed and adopted
// carry a final answer a dependent may start from; skipped and cancelled do
// not, and the difference is the whole reason the distinction exists.
func terminalAnswerRow(before, after Observation) row {
return valueRow("items holding an answer a dependent may use", "agentgraph.NodeState.Answered",
"none observed", "derived from the disposition, which is not recorded",
answeredSummary(before), answeredSummary(after), false)
}
// adoptSourceRow is the provenance an adoption carries: whose answer stood in.
// Reuse is only visible if the source is nameable — otherwise the picture shows
// work that cost nothing with no way to say what paid for it. The comparison is
// the graph's edge against the journal's opening, so a record that named a
// different source would read as a loss rather than as agreement.
func adoptSourceRow(before, after Observation) row {
return valueRow("whose answer an adoption reused", "the adopt edge, recorded with the opening",
"<stem>.execution.jsonl", "read back with the openings, never re-derived",
adoptEdges(before), journalAdoptions(after), true)
}
// journalAdoptions renders the openings' recorded sources the way the graph
// renders its edges, so the two sides of the row are the same measurement.
func journalAdoptions(o Observation) string {
var out []string
for _, e := range o.Executions {
if e.AdoptedFrom != "" {
out = append(out, e.ID+"<-"+e.AdoptedFrom)
}
}
sort.Strings(out)
return join(out)
}
// contextDerivabilityRow asks whether the delivery edge follows from facts that
// are already durable. A context edge that always accompanies an ordering edge
// whose upstream answered is derivable; one that does not is a fact of its own.
func contextDerivabilityRow(before, after Observation) row {
implied, actual := impliedContext(before), contextEdges(before)
verdict := verdictHolds
if implied != actual {
verdict = verdictViolated
}
return row{
Semantic: "whether a context edge follows from the durable facts",
Authority: "the ordering edges and what each upstream answered",
Artifact: "<stem>.execution.jsonl for the ordering; the disposition is not recorded",
Reconstruction: "an ordering edge whose upstream answered delivered its answer",
Before: "implied " + orNone(implied) + " / actual " + orNone(actual),
After: "implied " + orNone(impliedContext(after)) + " / actual: not observable",
Verdict: verdict,
}
}
// executedTerminals are the graph's terminals for items that actually ran.
// Adopted and skipped are excluded: nothing executed under them, so the store
// owes them no record and counting them would read a correct store as short.
func executedTerminals(o Observation) map[string]int {
out := map[string]int{}
for _, n := range o.Graph.Nodes {
if n.Kind == agentgraph.KindGroup || n.State == "" || !n.State.Terminal() {
continue
}
if n.State == agentgraph.StateAdopted || n.State == agentgraph.StateSkipped {
continue
}
out[string(n.State)]++
}
return out
}
// childOutcomeRow compares the two owners of the same fact. The graph draws
// completed, failed and cancelled apart; the store is what a restart reads. A
// terminal the graph distinguishes and the store folds into another is gone for
// good — no journal record could recover it, because the owner never kept it.
func childOutcomeRow(before, after Observation) row {
graphTerminals := executedTerminals(before)
verdict := verdictNotMeasured
if len(graphTerminals) > 0 {
verdict = verdictHolds
for state := range graphTerminals {
if !storeKnows(after, state) {
verdict = verdictViolated
}
}
}
return row{
Semantic: "terminal outcome: what the graph drew, what the store kept",
Authority: "the graph's closing delta against SubagentStore.Meta.Status",
Artifact: "subagents/<ref>.meta.json",
Reconstruction: "the store owns what happened to a child; every terminal it draws it must keep",
Before: "graph " + countSummary(graphTerminals) + " / store " + orNone(childStatusCounts(before)),
After: "store " + orNone(childStatusCounts(after)), Verdict: verdict,
}
}
// storeKnows reports whether any child carries this terminal as its status.
func storeKnows(o Observation, state string) bool {
for _, f := range o.Children.Facts {
if f.Status == state {
return true
}
}
return false
}
func childStatusCounts(o Observation) string {
counts := map[string]int{}
for _, f := range o.Children.Facts {
counts[f.Status]++
}
if len(counts) == 0 {
return ""
}
return countSummary(counts)
}
// identitySemanticsRow lays the three answers beside each other: what the
// caller named, what the graph shows, and what the store resolved. They are not
// expected to agree — the point is which of them a node's field is reporting,
// because a rebuild cannot be held to a field whose meaning is unsettled.
func identitySemanticsRow(before Observation) row {
store := map[string]string{}
for _, f := range before.Children.Facts {
store[f.ParentToolCallID] = orNone(f.Model) + "/" + orNone(f.Effort)
}
var out []string
for _, n := range before.Graph.Nodes {
if n.Kind != agentgraph.KindWorker {
continue
}
out = append(out, fmt.Sprintf("%s graph=%s/%s store=%s", n.ID,
orNone(n.Model), orNone(n.Effort), orNone(store[n.ID])))
}
sort.Strings(out)
return row{
Semantic: "worker identity: what each layer reports", Authority: "the fan-out's spec against the store's resolution",
Artifact: "subagents/<ref>.meta.json", Reconstruction: "the graph resolves a subagent chain; the store resolves the final identity",
Before: join(out), After: "not applicable: read before the boundary", Verdict: verdictStable,
}
}
// identityRebuildRow is the same question asked of the rebuild: whichever of
// those layers the field is meant to carry, does a restart get it back?
func identityRebuildRow(before, after Observation) row {
return valueRow("worker identity after a restart", "execgraph.Rebuild over journal + store",
"<stem>.execution.jsonl + subagents/<ref>.meta.json",
"the journal records no worker identity; the store records the one it resolved",
identitySummary(graphOf(before)), identitySummary(rebuiltGraph(after).Graph), false)
}
func graphOf(o Observation) agentgraph.Graph {
return agentgraph.Graph{Nodes: o.Graph.Nodes, Edges: o.Graph.Edges}
}
func terminalSummary(o Observation) string {
if len(o.Graph.Nodes) == 0 {
return ""
}
counts := map[string]int{}
for _, n := range o.Graph.Nodes {
if n.Kind != agentgraph.KindGroup && n.State != "" {
counts[string(n.State)]++
}
}
return countSummary(counts)
}
// lifecycleSummary renders each item's durable transitions, which is what a
// restart actually has. Two items printing the same line here are two items a
// restart cannot tell apart.
func lifecycleSummary(o Observation) string {
if len(o.Executions) == 0 {
return ""
}
out := make([]string, 0, len(o.Executions))
for _, e := range o.Executions {
marks := []string{"open"}
if e.Disposition != "" && e.Disposition != "pending" {
marks = append(marks, e.Disposition)
}
if e.Queued() {
marks = append(marks, "queued("+e.Cause+")")
}
if e.Started() {
marks = append(marks, "started")
}
if !e.SettledAt.IsZero() {
marks = append(marks, "settled")
}
out = append(out, e.ID+":"+strings.Join(marks, "+"))
}
sort.Strings(out)
return join(out)
}
func answeredSummary(o Observation) string {
var out []string
for _, n := range o.Graph.Nodes {
if n.Kind != agentgraph.KindGroup && n.State.Answered() {
out = append(out, n.ID)
}
}
sort.Strings(out)
return join(out)
}
func adoptEdges(o Observation) string {
var out []string
for _, e := range o.Graph.Edges {
if e.Kind == agentgraph.Adopt {
out = append(out, e.To+"<-"+e.From)
}
}
sort.Strings(out)
return join(out)
}
func contextEdges(o Observation) string {
var out []string
for _, e := range o.Graph.Edges {
if e.Kind == agentgraph.Context {
out = append(out, e.To+"<-"+e.From)
}
}
sort.Strings(out)
return join(out)
}
// impliedContext is what the durable facts alone would predict: every ordering
// edge whose upstream ended up answered.
func impliedContext(o Observation) string {
answered := map[string]bool{}
for _, n := range o.Graph.Nodes {
if n.State.Answered() {
answered[n.ID] = true
}
}
var out []string
for _, e := range o.Executions {
for _, up := range e.DependsOn {
if answered[up] {
out = append(out, e.ID+"<-"+up)
}
}
}
sort.Strings(out)
return join(out)
}
func countSummary(counts map[string]int) string {
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k]))
}
return strings.Join(parts, " ")
}
// terminalArmInvalid refuses an arm that never reached its disposition. Every
// row below it compares against a shape that was not established, and reporting
// those as evidence is how a probe answers a question nobody asked.
func terminalArmInvalid(arm string, before Observation) string {
want := wantedTerminal(arm)
if len(nodesInState(graphOf(before), want)) == 0 {
return "no item reached " + string(want) + "; the fan-out ended as " + orNone(terminalSummary(before))
}
if arm == armTerminalContext && contextEdges(before) == "" {
return "the fan-out delivered no upstream answer, so there is no context edge to derive"
}
if arm == armTerminalCancelled && len(nodesInState(graphOf(before), agentgraph.StateSkipped)) > 0 {
return "an item was skipped rather than cancelled; parallel_tasks' skipped branch was thought unreachable"
}
return ""
}
-111
View File
@@ -1,111 +0,0 @@
package main
import (
"fmt"
"slices"
)
// Contract verdicts for an arm that asserts a fact rather than comparing two
// readings: the note either satisfies what todoIdentityProjection claims about
// itself, or it does not.
const (
verdictHolds = "holds"
verdictViolated = "VIOLATED"
)
// todoRows judge the host's step-identity note against the three things it
// claims: there is one of it, it rides the turn tail, and it names the identity
// the host holds now. Each is checked on both sides of the process boundary.
func todoRows(before, after Observation) []row {
return []row{
todoRow("host identity notes in the view", "one note when the history lost the ids, none when it kept them",
before, after, noteCount, exactlyOnce),
todoRow("where the identity note sits", "the note rides the live tail, as a turn-tail note",
before, after, notePlacement, ridesTheTail),
todoRow("the identity the note carries", "the note names the host's current step ids",
before, after, noteIdentity, matchesHost),
}
}
func todoRow(semantic, contract string, before, after Observation,
render func(Observation) string, holds func(Observation) bool) row {
verdict := verdictHolds
switch {
case before.TodoNotes.ViewLen == 0 || after.TodoNotes.ViewLen == 0:
verdict = verdictNotMeasured
case !holds(before) || !holds(after):
verdict = verdictViolated
}
return row{
Semantic: semantic, Authority: "agent todoState (host-owned)",
Artifact: "none: derived for each request, stored nowhere",
Reconstruction: contract,
Before: render(before), After: render(after), Verdict: verdict,
}
}
func noteCount(o Observation) string {
owed := "history lost the ids"
if o.TodoNotes.ReadableInHistory {
owed = "history still shows them"
}
return fmt.Sprintf("%d (%s)", o.TodoNotes.Count, owed)
}
// exactlyOnce is conditional on need: a request whose history still shows the
// ids is owed nothing, and demanding a note there would turn the migration into
// an unconditional append.
func exactlyOnce(o Observation) bool {
if o.TodoNotes.ReadableInHistory {
return o.TodoNotes.Count == 0
}
return o.TodoNotes.Count == 1
}
// notePlacement reports the note's index against the end of the frozen body.
// An index below it means the note was frozen ahead of messages the splice adds
// after it, which is the opposite of riding the tail.
func notePlacement(o Observation) string {
n := o.TodoNotes
if n.Count == 0 {
return "no note"
}
where := "after the live tail"
if n.Indexes[len(n.Indexes)-1] < n.BodyLen {
where = "inside the frozen body"
}
return fmt.Sprintf("index %v of %d, body ends at %d — %s", n.Indexes, n.ViewLen, n.BodyLen, where)
}
// ridesTheTail passes vacuously when no note is owed: placement is a claim
// about a note that exists.
func ridesTheTail(o Observation) bool {
n := o.TodoNotes
if n.Count == 0 {
return n.ReadableInHistory
}
return n.Indexes[len(n.Indexes)-1] >= n.BodyLen
}
func noteIdentity(o Observation) string {
n := o.TodoNotes
return fmt.Sprintf("note %s / host %s", join(dedupe(n.IDs)), join(n.HostIDs))
}
func matchesHost(o Observation) bool {
n := o.TodoNotes
if n.Count == 0 {
return n.ReadableInHistory
}
return len(n.HostIDs) > 0 && slices.Equal(dedupe(n.IDs), n.HostIDs)
}
func dedupe(in []string) []string {
var out []string
for _, s := range in {
if !slices.Contains(out, s) {
out = append(out, s)
}
}
return out
}
-356
View File
@@ -1,356 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/frontend/serve"
"reasonix/internal/session/control"
"reasonix/internal/state/execjournal"
)
// armUIGraphMixed is the only arm that reads the frontend rather than the host.
// Everything below it has been shown to rebuild the graph exactly; this asks
// the question a value comparison cannot: through which door did each fact
// reach the view? A history replayed as live deltas settles on the same state
// and tells the user work is starting that ended in a process now dead.
const armUIGraphMixed = "ui-graph-mixed"
func uiArm(name string) bool { return name == armUIGraphMixed }
// The two extra fleets this arm's turn opens. The settled one ends before the
// death, which is the only way a skip exists at all — a fan-out publishes one in
// its closing delta — and the live one is dispatched by the resumed process.
const (
fleetSettledSentinel = "PROBE-FLEET-SETTLED"
fleetLiveSentinel = "PROBE-FLEET-LIVE"
)
// envProbeSabotage names a deliberate defect to run under. The arm must go red
// with it and green without: a probe nobody has watched fail is a decoration.
const envProbeSabotage = "REASONIX_PROBE_SABOTAGE"
// envProbeRepo carries the checkout to the child that has to build the observer.
// A child's working directory is its own workspace, so nothing else in the
// process can find the frontend.
const envProbeRepo = "REASONIX_PROBE_REPO"
const (
sabotagePublish = "publish"
sabotageTrajectory = "trajectory"
)
// settledFleet runs to completion with its dependent skipped, so the death
// inherits a terminal the mid-flight fleet cannot produce.
func settledFleet() []provider.Chunk {
return fleetCall("probe_fleet_settled", []map[string]any{
{"id": "up", "prompt": childFail + " settled upstream", "description": "fails", "read_only": true},
{"id": "down", "prompt": childDone + " settled downstream", "description": "skipped by up",
"read_only": true, "depends_on": []string{"up"}},
})
}
// uiMixedFleet is the density fleet plus one item the session ceiling refuses,
// so the death also holds a node that was queued and never admitted.
func uiMixedFleet(adoptRef string) []provider.Chunk {
return fleetCall("probe_fleet_mixed", []map[string]any{
{"id": "m1", "prompt": childDone + " mixed one", "description": "completes", "read_only": true},
{"id": "m2", "prompt": childFail + " mixed two", "description": "fails", "read_only": true},
{"id": "m3", "prompt": childDone + " mixed three", "description": "blocked by m2",
"read_only": true, "depends_on": []string{"m2"}},
{"id": "m4", "adopt_ref": adoptRef, "description": "adopted"},
{"id": "m5", "prompt": childHang + " mixed five", "description": "running at death",
"write_paths": []string{"probe-fanout-out"}},
{"id": "m6", "prompt": childHang + " mixed six", "description": "queued at death", "read_only": true},
})
}
// liveFleet is what the resumed process dispatches while the view is watching.
// Without it a frontend that had stopped folding deltas entirely would pass
// every other row here.
func liveFleet() []provider.Chunk {
return fleetCall("probe_fleet_live", []map[string]any{
{"id": "l1", "prompt": childDone + " live one", "description": "starts after the resume", "read_only": true},
{"id": "l2", "prompt": childDone + " live two", "description": "starts after the resume", "read_only": true},
})
}
// livePrompt opens that fleet from the frontend, through /submit.
func livePrompt() string { return fanOutTurn(200, fleetLiveSentinel) }
// uiBus is the broadcaster the one arm with a frontend needs beside the probe's
// own fold: in the dying process it is what records the wire log a window would
// have recorded, and in the resumed one it is what the client reads.
func uiBus(arm string, sink *graphSink) (*serve.Broadcaster, event.Sink) {
if !uiArm(arm) {
return nil, sink
}
bc := serve.NewBroadcaster()
return bc, teeSink{to: []event.Sink{sink, bc}}
}
// recordLikeAWindow attaches the wire log the trajectory is read from. The
// server is never served: the constructor is what subscribes the recorder, and
// the dying process needs the record, not a socket.
func recordLikeAWindow(ctrl control.SessionAPI, bc *serve.Broadcaster) {
if bc != nil {
_ = serve.New(ctrl, bc, config.ServeConfig{})
}
}
// teeSink lets one runtime's events reach both the probe's fold and the server
// the frontend reads. Nothing production does this; the arm needs the same
// stream on two surfaces.
type teeSink struct{ to []event.Sink }
func (t teeSink) Emit(e event.Event) {
for _, s := range t.to {
s.Emit(e)
}
}
// UIEntry is one state the view passed through, as the frontend's own store
// reports it. States is what the graph showed, keyed by node.
type UIEntry struct {
Phase string `json:"phase"`
Origin string `json:"origin"`
SessionID string `json:"sessionId"`
States map[string]string `json:"states"`
Interruptions []UIInterruption `json:"interruptions"`
IdentityUnknown []string `json:"identityUnknown"`
}
type UIInterruption struct {
Execution string `json:"execution"`
Kind string `json:"kind"`
}
// UIObs is the whole observation sequence, not a final value: the defect this
// arm exists to catch agrees with the host on every final value.
type UIObs struct {
Trace []UIEntry `json:"trace,omitempty"`
// Deltas is what each publication the view folded named. A republished
// history moves no visible state and is still the kernel saying that work
// which ended in a dead process is happening now.
Deltas [][]string `json:"deltas,omitempty"`
Err string `json:"err,omitempty"`
}
// observeUI serves the resumed session on a loopback listener and runs the real
// client against it. The server is the production one and the client is the
// production one; only the shell between them is the probe's, which is what
// lets the trace be read as what a user could have seen.
func observeUI(root armRoot, ctrl control.SessionAPI, bc *serve.Broadcaster, obs Observation) UIObs {
ln, err := serve.ListenLoopback()
if err != nil {
return UIObs{Err: "listen: " + err.Error()}
}
defer ln.Close()
srv := serve.New(ctrl, bc, config.ServeConfig{})
handler := srv.Handler()
if os.Getenv(envProbeSabotage) == sabotagePublish {
handler = republishOnce(handler, func() { publishRebuiltGraph(bc, obs) })
}
go func() { _ = http.Serve(ln, handler) }()
bundle, err := bundleObserver(root)
if err != nil {
return UIObs{Err: err.Error()}
}
out, runErr := runObserver(bundle, serve.LoopbackOrigin(ln))
seen := parseTrace(out)
if runErr != nil && seen.Err == "" {
seen.Err = runErr.Error()
}
return seen
}
// bundleObserver builds the observer with the frontend's own toolchain. It is
// the client's code, imported the way the app imports it — a hand-written stand
// -in would be measuring the probe.
func bundleObserver(root armRoot) (string, error) {
repo := strings.TrimSpace(os.Getenv(envProbeRepo))
if repo == "" {
return "", fmt.Errorf("no checkout named in %s, so the frontend could not be built", envProbeRepo)
}
dir := filepath.Join(repo, "desktop", "frontend-next")
esbuild := filepath.Join(dir, "node_modules", ".bin", "esbuild")
if _, err := os.Stat(esbuild); err != nil {
return "", fmt.Errorf("the frontend's toolchain is not installed: %w", err)
}
out := filepath.Join(root.Dir, "graph-observer.mjs")
cmd := exec.Command(esbuild, filepath.Join("src", "e2e", "graph_observer.ts"),
"--bundle", "--platform=node", "--format=esm", "--outfile="+out)
cmd.Dir = dir
if msg, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("bundle the observer: %w: %s", err, strings.TrimSpace(string(msg)))
}
return out, nil
}
func runObserver(bundle, base string) ([]byte, error) {
cmd := exec.Command("node", bundle, "--base", base, "--prompt", livePrompt(),
"--sabotage", os.Getenv(envProbeSabotage))
cmd.Stderr = os.Stderr
return cmd.Output()
}
// parseTrace reads the observer's last line. Anything it printed before that is
// the frontend's own noise, which belongs on the terminal and not in the row.
func parseTrace(out []byte) UIObs {
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
var obs UIObs
if len(lines) == 0 {
return UIObs{Err: "the observer printed nothing"}
}
if err := json.Unmarshal([]byte(lines[len(lines)-1]), &obs); err != nil {
return UIObs{Err: "the observer's answer did not parse: " + err.Error()}
}
return obs
}
// republishOnce fires the sabotage after a client has read the snapshot, which
// is the only window where it can be seen at all: what a resume publishes before
// that is numbered below the watermark the client then resumes past, and the
// bootstrap absorbs it. The defect this models is the Studio path — a pane that
// is already attached when a session is resumed under it.
func republishOnce(next http.Handler, fire func()) http.Handler {
var once sync.Once
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
if r.URL.Path == "/execution-graph" {
once.Do(func() { go fire() })
}
})
}
// publishRebuiltGraph is the sabotage itself: a resume that helpfully republishes
// what it rebuilt, which is how a view gets told that finished work is starting
// now. Production must never do this, and the arm's own rows are what says so.
func publishRebuiltGraph(sink event.Sink, obs Observation) {
rebuilt := rebuiltGraph(obs)
for _, n := range rebuilt.Graph.Nodes {
sink.Emit(event.Event{Kind: event.GraphDelta, Graph: &agentgraph.Delta{Nodes: []agentgraph.Node{n}}})
}
if len(rebuilt.Graph.Edges) > 0 {
sink.Emit(event.Event{Kind: event.GraphDelta, Graph: &agentgraph.Delta{Edges: rebuilt.Graph.Edges}})
}
// The publication has to reach the socket before the client attaches, or the
// sabotage would be measuring a race instead of a defect.
time.Sleep(200 * time.Millisecond)
}
// historicalIDs is every node the dying process had drawn, groups included: a
// group redrawn as running is the same lie about the same dead process.
func historicalIDs(o Observation) map[string]bool {
out := map[string]bool{}
for _, n := range o.Graph.Nodes {
out[n.ID] = true
}
return out
}
// firstSight is the origin each node carried the first time the view held it.
func firstSight(trace []UIEntry) map[string]string {
out := map[string]string{}
for _, e := range trace {
for id := range e.States {
if _, seen := out[id]; !seen {
out[id] = e.Origin
}
}
}
return out
}
var liveStates = map[string]bool{"pending": true, "queued": true, "running": true}
// ghostSightings are the historical nodes the view ever drew as someone's work
// in progress. One render tick is enough: the user saw it.
func ghostSightings(trace []UIEntry, historical map[string]bool) []string {
seen := map[string]bool{}
for _, e := range trace {
for id, state := range e.States {
if historical[id] && liveStates[state] {
seen[id+":"+state] = true
}
}
}
return sortedKeys(seen)
}
// rebirths are nodes the view lost and introduced again. A snapshot replace is
// not a rebirth; leaving a state and coming back inside one session is.
func rebirths(trace []UIEntry) []string {
gone, twice := map[string]bool{}, map[string]bool{}
held := map[string]bool{}
for _, e := range trace {
if e.Phase == "loading" {
gone, held = map[string]bool{}, map[string]bool{}
continue
}
for id := range held {
if _, still := e.States[id]; !still {
gone[id] = true
}
}
for id := range e.States {
if gone[id] {
twice[id] = true
}
}
held = map[string]bool{}
for id := range e.States {
held[id] = true
}
}
return sortedKeys(twice)
}
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// wantedInterruptions is what the host says, formatted the way the view reports
// it. The row compares the two rather than a hand-written expectation.
func wantedInterruptions(o Observation) string {
var out []string
for _, i := range rebuiltGraph(o).Interrupted {
kind := execjournal.InterruptedBeforeStart
if i.Started {
kind = execjournal.InterruptedDuringExecution
}
out = append(out, i.Execution+":"+kind)
}
sort.Strings(out)
return join(out)
}
func shownInterruptions(trace []UIEntry) string {
if len(trace) == 0 {
return ""
}
var out []string
for _, i := range trace[len(trace)-1].Interruptions {
out = append(out, i.Execution+":"+i.Kind)
}
sort.Strings(out)
return join(out)
}
-184
View File
@@ -1,184 +0,0 @@
package main
import (
"fmt"
"sort"
)
// The rows this arm exists for. Every other graph row compares values; these
// compare doors. A frontend that rebuilt the same graph by replaying history as
// live transitions would pass all of those and fail all of these.
const uiAuthority = "the frontend's own store, read through the real transport"
func uiRows(before, after Observation) []row {
ui := after.UI
if ui == nil || len(ui.Trace) == 0 {
return []row{uiRow("the view's observation sequence", "a sequence of visible states",
orNone(uiFailure(ui)), verdictNotMeasured)}
}
historical := historicalIDs(before)
first := firstSight(ui.Trace)
return []row{
uiFirstPictureRow(ui.Trace),
uiHistoryOriginRow(historical, first),
uiGhostRow(ui.Trace, historical),
uiInterruptionRow(ui.Trace, after),
uiLiveRow(historical, first),
uiRebirthRow(ui.Trace),
uiRepublishRow(historical, ui.Deltas),
}
}
func uiFailure(ui *UIObs) string {
if ui == nil {
return ""
}
return ui.Err
}
func uiRow(semantic, want, got, verdict string) row {
return row{
Semantic: semantic, Authority: uiAuthority, Artifact: "none — a sequence, not a value",
Reconstruction: want, Before: want, After: got, Verdict: verdict,
}
}
func held(ok bool) string {
if ok {
return verdictHolds
}
return verdictViolated
}
// uiFirstPictureRow reads the handover itself: nothing, then the authority. A
// graph that appears from a delta was assembled out of transitions, whatever it
// settles on afterwards.
func uiFirstPictureRow(trace []UIEntry) row {
got := trace[0].Phase
for _, e := range trace {
if len(e.States) > 0 {
got = trace[0].Phase + " → " + e.Origin
break
}
}
return uiRow("the first graph a resumed view draws", "loading → snapshot", got, held(got == "loading → snapshot"))
}
// uiHistoryOriginRow is the arm's whole point: an execution the dead process
// opened may enter the view only as part of an answer, never as news.
func uiHistoryOriginRow(historical map[string]bool, first map[string]string) row {
var wrong []string
seen := 0
for id, origin := range first {
if !historical[id] {
continue
}
seen++
if origin != "snapshot" {
wrong = append(wrong, id+" via "+origin)
}
}
sort.Strings(wrong)
got := fmt.Sprintf("%d from the snapshot", seen)
if len(wrong) > 0 {
got = join(wrong)
}
return uiRow("how work from the dead process entered the view",
fmt.Sprintf("%d executions, each from the snapshot", len(historical)), got, held(len(wrong) == 0))
}
// uiGhostRow watches every frame, not the last one: a node shown as running for
// a single render and corrected afterwards was still shown as running.
func uiGhostRow(trace []UIEntry, historical map[string]bool) row {
ghosts := ghostSightings(trace, historical)
return uiRow("a dead run shown as work in progress",
"never pending, queued or running", orNone(join(ghosts)), held(len(ghosts) == 0))
}
// uiInterruptionRow compares the two lists that describe the same executions:
// what the host derived, and what the view told the user.
func uiInterruptionRow(trace []UIEntry, after Observation) row {
want, got := wantedInterruptions(after), shownInterruptions(trace)
return uiRow("interruptions the view names", orNone(want), orNone(got), held(want == got))
}
// uiLiveRow is the positive control. Without it a view that had stopped folding
// deltas altogether would satisfy every row above.
func uiLiveRow(historical map[string]bool, first map[string]string) row {
var wrong, fresh []string
for id, origin := range first {
if historical[id] {
continue
}
fresh = append(fresh, id)
if origin != "delta" {
wrong = append(wrong, id+" via "+origin)
}
}
sort.Strings(fresh)
sort.Strings(wrong)
switch {
case len(fresh) == 0:
return uiRow("work started after the resume", "at least one, from a delta",
"nothing new was ever drawn", verdictViolated)
case len(wrong) > 0:
return uiRow("work started after the resume", "each from a delta", join(wrong), verdictViolated)
}
return uiRow("work started after the resume", "each from a delta",
fmt.Sprintf("%d from a delta: %s", len(fresh), join(fresh)), verdictHolds)
}
// uiRebirthRow: a node the view dropped and drew again is a second birth to
// whatever the interface hangs off an introduction, however idempotent the fold
// underneath it was.
func uiRebirthRow(trace []UIEntry) row {
twice := rebirths(trace)
return uiRow("a node introduced twice in one session",
"each node introduced once", orNone(join(twice)), held(len(twice) == 0))
}
// uiRepublishRow is the rule underneath the first-appearance one, and the only
// one that catches a republication at a view that already holds the snapshot:
// after a resume, no delta may name work the dead process opened. Nothing in
// production produces one — an execution belongs to the process that opened it.
func uiRepublishRow(historical map[string]bool, deltas [][]string) row {
named := map[string]bool{}
for _, ids := range deltas {
for _, id := range ids {
if historical[id] {
named[id] = true
}
}
}
old := sortedKeys(named)
return uiRow("deltas naming work the dead process opened",
"none: history is answered, never published", orNone(join(old)), held(len(old) == 0))
}
// uiArmInvalid guards the premise. The states the death has to hold are the
// whole reason this arm can say anything about ghosts, and an observer that
// never ran leaves every row below it describing nothing.
func uiArmInvalid(before, after Observation) string {
if after.UI == nil || len(after.UI.Trace) == 0 {
return "the frontend was never observed: " + orNone(uiFailure(after.UI))
}
var missing []string
for _, state := range wantedStates(armUIGraphMixed) {
if !holdsState(before, string(state)) {
missing = append(missing, string(state))
}
}
if len(missing) > 0 {
return "the death did not hold " + join(missing) + ", so the view had nothing to get wrong about them"
}
return ""
}
func holdsState(o Observation, want string) bool {
for _, n := range o.Graph.Nodes {
if string(n.State) == want {
return true
}
}
return false
}
-119
View File
@@ -1,119 +0,0 @@
package main
import "testing"
// The shapes the probe exists to tell apart, written out rather than run: a
// full arm takes three processes and a live kernel, which is the wrong place to
// find out that a verdict was computed backwards.
func historyOf(ids ...string) map[string]bool {
out := map[string]bool{}
for _, id := range ids {
out[id] = true
}
return out
}
func entry(phase, origin string, states map[string]string) UIEntry {
return UIEntry{Phase: phase, Origin: origin, States: states}
}
func TestAHistoryReadBackHoldsEveryRow(t *testing.T) {
historical := historyOf("g", "g/1", "g/2")
trace := []UIEntry{
entry("loading", "reset", nil),
entry("live", "snapshot", map[string]string{"g": "", "g/1": "completed", "g/2": "failed"}),
entry("live", "delta", map[string]string{"g": "", "g/1": "completed", "g/2": "failed", "n/1": "running"}),
entry("live", "delta", map[string]string{"g": "", "g/1": "completed", "g/2": "failed", "n/1": "completed"}),
}
first := firstSight(trace)
for _, r := range []row{
uiFirstPictureRow(trace),
uiHistoryOriginRow(historical, first),
uiGhostRow(trace, historical),
uiLiveRow(historical, first),
uiRebirthRow(trace),
uiRepublishRow(historical, [][]string{{"n/1"}}),
} {
if r.Verdict != verdictHolds {
t.Errorf("%s = %s (%s), want %s", r.Semantic, r.Verdict, r.After, verdictHolds)
}
}
}
// The defect the arm was built for: the same final state, assembled out of
// transitions. Every value row elsewhere in this probe would still be green.
func TestHistoryArrivingAsNewsIsCaught(t *testing.T) {
historical := historyOf("g/1")
trace := []UIEntry{
entry("loading", "reset", nil),
entry("live", "delta", map[string]string{"g/1": "running"}),
entry("live", "delta", map[string]string{"g/1": "completed"}),
}
first := firstSight(trace)
if got := uiFirstPictureRow(trace); got.Verdict != verdictViolated {
t.Errorf("first picture = %s, want %s", got.Verdict, verdictViolated)
}
if got := uiHistoryOriginRow(historical, first); got.Verdict != verdictViolated {
t.Errorf("history origin = %s, want %s", got.Verdict, verdictViolated)
}
if got := uiGhostRow(trace, historical); got.Verdict != verdictViolated {
t.Errorf("ghost = %s, want %s", got.Verdict, verdictViolated)
}
}
// A snapshot the view holds and a delta that names the same work again: the
// state never moves, and the kernel still said finished work is happening.
func TestRepublishedHistoryIsCaughtWithoutMovingTheState(t *testing.T) {
historical := historyOf("g/1")
trace := []UIEntry{
entry("loading", "reset", nil),
entry("live", "snapshot", map[string]string{"g/1": "completed"}),
entry("live", "delta", map[string]string{"g/1": "completed"}),
}
if got := uiHistoryOriginRow(historical, firstSight(trace)); got.Verdict != verdictHolds {
t.Fatalf("history origin = %s, want %s: the snapshot did arrive first", got.Verdict, verdictHolds)
}
if got := uiRepublishRow(historical, [][]string{{"g/1"}}); got.Verdict != verdictViolated {
t.Errorf("republication = %s, want %s", got.Verdict, verdictViolated)
}
}
// A view that stopped folding deltas would satisfy every rule above, which is
// what the positive control is for.
func TestAViewThatNeverMovesAgainIsCaught(t *testing.T) {
historical := historyOf("g/1")
trace := []UIEntry{
entry("loading", "reset", nil),
entry("live", "snapshot", map[string]string{"g/1": "completed"}),
}
if got := uiLiveRow(historical, firstSight(trace)); got.Verdict != verdictViolated {
t.Errorf("live control = %s (%s), want %s", got.Verdict, got.After, verdictViolated)
}
}
// A node the view dropped and drew again, which is a second introduction to
// anything the interface hangs off one.
func TestANodeIntroducedTwiceIsCaught(t *testing.T) {
trace := []UIEntry{
entry("live", "snapshot", map[string]string{"g/1": "completed"}),
entry("live", "delta", map[string]string{}),
entry("live", "delta", map[string]string{"g/1": "completed"}),
}
if got := uiRebirthRow(trace); got.Verdict != verdictViolated {
t.Errorf("rebirth = %s (%s), want %s", got.Verdict, got.After, verdictViolated)
}
}
// A session switch is not a rebirth: the view is told to forget, and what comes
// back is the next conversation's first picture.
func TestASwitchIsNotARebirth(t *testing.T) {
trace := []UIEntry{
entry("live", "snapshot", map[string]string{"g/1": "completed"}),
entry("loading", "reset", map[string]string{}),
entry("live", "snapshot", map[string]string{"g/1": "completed"}),
}
if got := uiRebirthRow(trace); got.Verdict != verdictHolds {
t.Errorf("rebirth across a switch = %s (%s), want %s", got.Verdict, got.After, verdictHolds)
}
}
+1 -10
View File
@@ -7,7 +7,7 @@ import "fmt"
// arm costs more: did the extra agents produce verified work, or just tokens.
func renderDelegation(results []result) string {
var runs, nested, childCalls, parentCalls, mutations, dupes int
var reports, prose, falseDone, downgrades, violations int
var violations int
var scopeHints, namedFiles, evidencePaths, discoveredPaths int
solved, total, childTokens := 0, 0, 0
for _, r := range results {
@@ -24,10 +24,6 @@ func renderDelegation(results []result) string {
parentCalls += r.ToolCalls - r.SubagentToolCalls
mutations += r.SubagentMutations
dupes += r.DuplicateWorkPaths
reports += r.CompletionReports
prose += r.CompletionsProsedOnly
falseDone += r.FalseCompletions
downgrades += r.CriterionDowngrades
violations += r.WriteScopeViolations
scopeHints += r.ParentScopeHints
namedFiles += r.ParentNamedFiles
@@ -70,11 +66,6 @@ func renderDelegation(results []result) string {
if dupes > 0 {
b += fmt.Sprintf("- **duplicate work**: %d file(s) mutated by more than one child\n", dupes)
}
closed := reports + prose
b += fmt.Sprintf("- closed with a checkable claim: **%d/%d** (%s)\n", reports, closed, pct(reports, closed))
if falseDone > 0 {
b += fmt.Sprintf("- **false completions**: %d run(s), %d criterion claim(s) the host refused\n", falseDone, downgrades)
}
if violations > 0 {
b += fmt.Sprintf("- **write-scope violations**: %d\n", violations)
}
+2 -9
View File
@@ -27,9 +27,7 @@ func TestRenderDelegationExposesWhatDelegationCost(t *testing.T) {
runMetrics: runMetrics{
ToolCalls: 30, SubagentToolCalls: 24,
SubagentRuns: 3, SubagentNestedRuns: 1, SubagentMutations: 5,
DuplicateWorkPaths: 2,
CompletionReports: 2, CompletionsProsedOnly: 1,
FalseCompletions: 1, CriterionDowngrades: 3,
DuplicateWorkPaths: 2,
WriteScopeViolations: 1,
},
}})
@@ -38,8 +36,6 @@ func TestRenderDelegationExposesWhatDelegationCost(t *testing.T) {
"parent **6** tool calls",
"children **24**",
"duplicate work**: 2",
"checkable claim: **2/3**",
"false completions**: 1 run(s), 3 criterion",
"write-scope violations**: 1",
} {
if !strings.Contains(got, want) {
@@ -92,14 +88,11 @@ func TestRenderDelegationSaysWhenEvidenceOriginIsUnscored(t *testing.T) {
func TestRenderDelegationStaysQuietWhenNothingWentWrong(t *testing.T) {
got := renderDelegation([]result{{
Passed: true,
runMetrics: runMetrics{ToolCalls: 12, SubagentToolCalls: 8, SubagentRuns: 2, CompletionReports: 2},
runMetrics: runMetrics{ToolCalls: 12, SubagentToolCalls: 8, SubagentRuns: 2},
}})
for _, unwanted := range []string{"duplicate work", "false completions", "write-scope violations"} {
if strings.Contains(got, unwanted) {
t.Errorf("clean run reported %q:\n%s", unwanted, got)
}
}
if !strings.Contains(got, "checkable claim: **2/2**") {
t.Errorf("clean run lost its completion coverage:\n%s", got)
}
}
+2 -2
View File
@@ -243,11 +243,11 @@ func renderDiff(r diffReport) string {
fmt.Fprintf(&b, "| Tokens (prompt / completion) | %s / %s |\n", comma(r.m.PromptTokens), comma(r.m.CompletionTokens))
fmt.Fprintf(&b, "| Model calls | %d |\n", r.m.Steps)
fmt.Fprintf(&b, "| Cost | %s%.4f |\n", currencySym(r.m.Currency), r.m.Cost)
if r.m.CapabilityRoutes > 0 || r.m.CapabilitySkillInvocations > 0 || r.m.CapabilityMCPCall > 0 || r.m.ReadinessChecks > 0 {
if r.m.CapabilityRoutes > 0 || r.m.CapabilitySkillInvocations > 0 || r.m.CapabilityMCPCall > 0 {
fmt.Fprintf(&b, "| Capability routes (semantic) | %d (%d) |\n", r.m.CapabilityRoutes, r.m.CapabilitySemanticRoutes)
fmt.Fprintf(&b, "| Routed candidates (require / prefer / suggest / declined) | %d (%d / %d / %d / %d) |\n", r.m.CapabilityRoutedCandidates, r.m.CapabilityRoutedRequire, r.m.CapabilityRoutedPrefer, r.m.CapabilityRoutedSuggest, r.m.CapabilityDeclines)
fmt.Fprintf(&b, "| Skill invocations / MCP proxy calls | %d / %d |\n", r.m.CapabilitySkillInvocations, r.m.CapabilityMCPCall)
fmt.Fprintf(&b, "| Review blocks / readiness recoveries | %d / %d |\n", r.m.CapabilityReviewBlocks, r.m.ReadinessRecoveries)
fmt.Fprintf(&b, "| Review blocks | %d |\n", r.m.CapabilityReviewBlocks)
if r.m.CapabilityRouterCost > 0 || r.m.CapabilityRouterLatencyMs > 0 {
fmt.Fprintf(&b, "| Capability-router cost / latency | %s%.4f / %dms |\n", currencySym(r.m.Currency), r.m.CapabilityRouterCost, r.m.CapabilityRouterLatencyMs)
}
+5 -11
View File
@@ -66,15 +66,11 @@ type runMetrics struct {
// Delegation counters mirror internal/frontend/cli.RunMetrics. They are what makes a
// single-agent arm comparable against a delegated one for the same model.
SubagentRuns int `json:"subagent_runs,omitempty"`
SubagentNestedRuns int `json:"subagent_nested_runs,omitempty"`
SubagentMutations int `json:"subagent_mutations,omitempty"`
CompletionReports int `json:"completion_reports,omitempty"`
CompletionsProsedOnly int `json:"completions_prose_only,omitempty"`
FalseCompletions int `json:"false_completions,omitempty"`
CriterionDowngrades int `json:"criterion_downgrades,omitempty"`
WriteScopeViolations int `json:"write_scope_violations,omitempty"`
DuplicateWorkPaths int `json:"duplicate_work_paths,omitempty"`
SubagentRuns int `json:"subagent_runs,omitempty"`
SubagentNestedRuns int `json:"subagent_nested_runs,omitempty"`
SubagentMutations int `json:"subagent_mutations,omitempty"`
WriteScopeViolations int `json:"write_scope_violations,omitempty"`
DuplicateWorkPaths int `json:"duplicate_work_paths,omitempty"`
// Evidence origin: what the parent's own delegation text scoped and named,
// and how much of what the children looked at they had to find themselves.
ParentScopeHints int `json:"parent_scope_hints,omitempty"`
@@ -85,8 +81,6 @@ type runMetrics struct {
// graph, which is where a scheduling wait is recorded and nowhere else.
Fanout *fanoutMetrics `json:"fanout,omitempty"`
// Optional Delivery capability counters (omitempty for baseline/old metrics).
ReadinessChecks int `json:"readiness_checks,omitempty"`
ReadinessRecoveries int `json:"readiness_recoveries,omitempty"`
CapabilityRoutes int `json:"capability_routes,omitempty"`
CapabilityRoutedCandidates int `json:"capability_routed_candidates,omitempty"`
CapabilityRoutedRequire int `json:"capability_routed_require,omitempty"`
+1 -1
View File
@@ -129,7 +129,7 @@
!macro rxScript
FileOpen $0 "$PLUGINSDIR\rxclose.ps1" w
FileWrite $0 "param([string]$$Dir, [string]$$Mode, [int]$$Self)$\r$\n"
FileWrite $0 "$$names = @('${APP_EXECUTABLE_FILENAME}', 'reasonix-studio-host.exe', 'cua-driver.exe')$\r$\n"
FileWrite $0 "$$names = @('${APP_EXECUTABLE_FILENAME}', 'reasonix-studio-host.exe')$\r$\n"
FileWrite $0 "$$hit = @(Get-CimInstance -ClassName Win32_Process | ? { $$_.ProcessId -ne $$Self -and (($$_.Path -and $$_.Path.StartsWith($$Dir, 'CurrentCultureIgnoreCase')) -or (-not $$_.Path -and $$names -contains $$_.Name)) })$\r$\n"
FileWrite $0 "if ($$Mode -eq 'find') { if ($$hit.Count -gt 0) { exit 0 } else { exit 1 } }$\r$\n"
FileWrite $0 "$$hit | % { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }$\r$\n"
+1 -1
View File
@@ -33,7 +33,7 @@
"launcher-live": "node test/launcher_live.mjs"
},
"devDependencies": {
"electron": "44.7.2",
"electron": "45.1.0",
"electron-builder": "^26.18.0",
"playwright": "1.64.1"
},
+35
View File
@@ -0,0 +1,35 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
// Exit 0 when the artifact is missing or any file under the given paths is
// newer than it, 1 when it is up to date. run-studio.sh asks this before
// rebuilding; the shell has no portable `find` on every platform it runs on.
const [artifact, ...inputs] = process.argv.slice(2);
if (!artifact) process.exit(2);
let built;
try {
built = fs.statSync(artifact).mtimeMs;
} catch {
process.exit(0);
}
const SKIP = new Set(["node_modules", ".git", "dist"]);
function newer(entry) {
let info;
try {
info = fs.statSync(entry);
} catch {
return false;
}
if (!info.isDirectory()) return info.mtimeMs > built;
for (const name of fs.readdirSync(entry)) {
if (SKIP.has(name)) continue;
if (newer(path.join(entry, name))) return true;
}
return false;
}
process.exit(inputs.some(newer) ? 0 : 1);
+5 -5
View File
@@ -9,8 +9,8 @@ importers:
.:
devDependencies:
electron:
specifier: 44.7.2
version: 44.7.2([email protected])
specifier: 45.1.0
version: 45.1.0([email protected])
electron-builder:
specifier: ^26.18.0
version: 26.18.0([email protected])([email protected])
@@ -374,8 +374,8 @@ packages:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'}
electron@44.7.2:
resolution: {integrity: sha512-u3Pbz0Hi1sSSwBMz2v8LiagsfUG4QURPUe/qphM+g4THKP8o4BYdnuboyhlLO8HIC/KfV5rvltyacbP4jEq9yw==}
electron@45.1.0:
resolution: {integrity: sha512-i7qKVAh/KR5v1pf56NhGGX2QYHcdjvLgS7koL6ui50c+zUDGLRunR95nXcTpHPURKOQ4GDBG8BmXiDgOQ8DD0A==}
engines: {node: '>= 22.12.0'}
hasBin: true
@@ -1551,7 +1551,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron@44.7.2([email protected]):
electron@45.1.0([email protected]):
dependencies:
'@electron-internal/extract-zip': 1.0.5
'@electron/get': 5.1.0([email protected])
+22 -4
View File
@@ -12,11 +12,29 @@ if [ ! -d "$here/node_modules" ]; then
(cd "$here" && pnpm install --frozen-lockfile)
fi
echo "==> frontend"
(cd "$here/../frontend-next" && pnpm install --frozen-lockfile >/dev/null && pnpm build >/dev/null)
# A step is skipped when nothing it reads is newer than what it produced;
# STUDIO_REBUILD=1 forces every step.
stale() {
[ -n "${STUDIO_REBUILD:-}" ] || node "$here/packaging/stale.js" "$@"
}
echo "==> kernel"
(cd "$here" && pnpm build:host)
root="$here/../.."
web="$here/../frontend-next"
host=$(ls "$here"/bin/reasonix-studio-host* 2>/dev/null | head -n 1 || true)
if stale "$web/dist/index.html" "$web/src" "$web/public" "$web/index.html" "$web/package.json" "$web/pnpm-lock.yaml" "$web/vite.config.ts" "$web/tsconfig.json"; then
echo "==> frontend"
(cd "$web" && pnpm install --frozen-lockfile >/dev/null && pnpm build >/dev/null)
else
echo "==> frontend (up to date)"
fi
if [ -z "$host" ] || stale "$host" "$root/internal" "$root/cmd" "$root/go.mod" "$root/go.sum" "$here/packaging"; then
echo "==> kernel"
(cd "$here" && pnpm build:host)
else
echo "==> kernel (up to date)"
fi
echo "==> studio"
cd "$here" && exec pnpm start
+22
View File
@@ -0,0 +1,22 @@
const net = require("node:net");
function probeHost(origin, timeoutMs = 4000) {
return new Promise((resolve, reject) => {
const url = new URL(origin);
const socket = net.connect({ host: url.hostname, port: Number(url.port) });
const fail = (cause) => {
socket.destroy();
const err = new Error(`the host at ${origin} did not accept a connection from the shell (${cause})`);
err.code = "unreachable";
reject(err);
};
socket.setTimeout(timeoutMs, () => fail(`no answer in ${timeoutMs / 1000}s`));
socket.once("error", (e) => fail(e.code || e.message));
socket.once("connect", () => {
socket.destroy();
resolve();
});
});
}
module.exports = { probeHost };
+2
View File
@@ -39,6 +39,7 @@ const { processMemoryHandler } = require("./procmem");
const { captureHandler } = require("./capture");
const { createPowerGuard, keepsAwake } = require("./powerguard");
const { openLogs, redactArgv, failStartup } = require("./shelllog");
const { probeHost } = require("./hostprobe");
const { raise } = require("./raise");
const { keepCredential, restoreIfStale } = require("./credential");
const { actHandler } = require("./acts");
@@ -223,6 +224,7 @@ cua = await startEmbedded(computerDriver, contract, text => logs.host.raw(text))
tray = installTray(client, { onOpen: showWindow, onQuit: () => app.quit() });
win.on("close", onWindowClose);
hostAgentBrowser();
await probeHost(origin);
await win.loadURL(origin + PAGE_PATH);
await tray?.refresh();
// After the window, deliberately. This asks about an install left behind by
+4
View File
@@ -172,6 +172,10 @@ const HINTS = {
"The kernel program could not be run. Security software blocking or locking it is a common cause.",
"无法运行内核程序。安全软件拦截或占用它是常见原因。",
],
unreachable: [
"The kernel is running, but this program cannot connect to it. A firewall or security product that blocks inbound connections for programs without an allow rule is the usual cause on Windows: allow Reasonix Studio and its reasonix-studio-host program, then start again.",
"内核已在运行,但本程序连不上它。在 Windows 上,通常是防火墙或安全软件对没有放行规则的程序拦截了入站连接:请放行 Reasonix Studio 和它的 reasonix-studio-host 程序,然后重新启动。",
],
};
function startupFailure(locale, reason, logDir, code) {
+20 -1
View File
@@ -718,7 +718,7 @@ test("a kernel that never answers is started once and not retried", { skip: proc
test("each failure cause gets its own explanation in both languages", () => {
const { startupFailure } = require("../src/shelllog.js");
const seen = new Set();
for (const code of ["timeout", "exited", "spawn"]) {
for (const code of ["timeout", "exited", "spawn", "unreachable"]) {
for (const locale of ["en", "zh-CN"]) {
const { detail } = startupFailure(locale, "r", "/logs", code);
assert.ok(!seen.has(detail), `${code}/${locale} repeated another cause's text`);
@@ -728,6 +728,17 @@ test("each failure cause gets its own explanation in both languages", () => {
assert.equal(startupFailure("en", "r", "/logs", "overflow").detail, "r\n\nLogs are in:\n/logs");
});
test("the shell finds out at once that the host it started cannot be reached", async () => {
const net = require("node:net");
const { probeHost } = require("../src/hostprobe.js");
const server = net.createServer((socket) => socket.end());
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address();
await probeHost(`http://127.0.0.1:${port}`, 2000);
await new Promise((resolve) => server.close(resolve));
await assert.rejects(probeHost(`http://127.0.0.1:${port}`, 2000), (err) => err.code === "unreachable");
});
// A fake child: only the stdout half readActs touches, and a way to push bytes
// through it in whatever chunks the test wants -- which is the point, since the
// bug this guards is a line split across two of them.
@@ -3921,3 +3932,11 @@ test("a pick arms the page, answers what it reports, and a page that cannot answ
}
}
});
test("the installer's by-name fallback for a process it cannot read the path of never names the shared computer-use driver", () => {
const nsh = fs.readFileSync(new URL("../assets/installer.nsh", import.meta.url), "utf8");
const listed = /\$\$names = @\(([^)]*)\)/.exec(nsh)?.[1] ?? "";
assert.ok(listed.includes("${APP_EXECUTABLE_FILENAME}"), "the app's own executable is matched by name");
assert.ok(listed.includes("reasonix-studio-host.exe"), "the shell's own host is matched by name");
assert.ok(!listed.includes("cua-driver.exe"), "other computer-use apps ship the same driver name, so a path-less one is not ours");
});
+5 -2
View File
@@ -36,6 +36,9 @@
"@mantine/core": "9.7.4",
"@mantine/hooks": "9.7.4",
"@noble/curves": "2.4.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"codemirror": "^6.0.2",
"docx-preview": "0.4.1",
"dompurify": "^3.4.17",
@@ -43,10 +46,10 @@
"katex": "^0.19.1",
"lucide-react": "^1.64.0",
"maplibre-gl": "6.16.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"papaparse": "^5.7.0",
"pdfjs-dist": "^6.4.299",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-map-gl": "8.1.3",
"react-markdown": "^10.1.0",
"recharts": "3.10.1",
+1
View File
@@ -43,6 +43,7 @@ node perf/rail-toggle-panes.mjs # Rail-fold frame cost with 1/3/6 panes; existin
node perf/verify.mjs # 行为验证:跟随、块的装卸、切页往返、大会话树
node perf/panels.mjs # 两侧栏:收起补间、拖动改宽、上下限、窄屏退场
node perf/look.mjs # 外观验证:字号、界面缩放、自定义字体、壁纸上传与调节(含焦点)
node perf/versions-row.mjs # Version rows: download/rollback, notes, restart-ready and the 600px breakpoint in English and Chinese
node perf/measure.mjs # Custom conversation percentage, preset retention, narrow composer controls and populated phone layout
node perf/theme-schedule.mjs # Scheduled appearance: daily boundaries, midnight, reload/resume, modes, active packs and DST; THEME_SCHEDULE_SHOTS writes wide/phone light/dark screenshots
node perf/pick.mjs # 补全菜单:/ 和 @ 的选中态,深浅两色下各走一遍
@@ -19,11 +19,11 @@ for (const scheme of ["light", "dark"]) {
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(PAGE, { waitUntil: "domcontentloaded" });
await page.waitForSelector('.projects-heading [data-action="workspace.add"]');
await page.waitForSelector('.projects-header [data-action="workspace.add"]');
const current = () => page.evaluate(() => [...document.querySelectorAll(".wsnode[data-current]")].map((el) => el.textContent ?? ""));
const was = await current();
if (SHOTS) await page.screenshot({ path: join(SHOTS, `${scheme}-before.png`) });
await page.locator('.projects-heading [data-action="workspace.add"]').click();
await page.locator('.projects-header [data-action="workspace.add"]').click();
await page.getByRole("dialog", { name: "打开项目", exact: true }).getByRole("button", { name: "选择本机文件夹", exact: true }).click();
await page.waitForFunction(() => [...document.querySelectorAll(".wsnode[data-current]")].some((el) => el.textContent?.includes("mock-workspace")), null, { timeout: 5000 }).catch(() => {});
const now = await current();
@@ -16,11 +16,11 @@ try {
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: theme, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".projects-heading");
await page.waitForSelector(".projects-header");
await page.mouse.move(width - 2, 790);
await page.waitForTimeout(300);
const g = await page.evaluate(() => {
const row = document.querySelector(".projects-heading");
const row = document.querySelector(".projects-header");
const add = row?.querySelector('[data-action="workspace.add"]');
if (!add) return null;
const r = add.getBoundingClientRect();
+3 -3
View File
@@ -43,11 +43,11 @@ for (const scheme of ["light", "dark"]) {
});
await page.waitForSelector(".studio-deckchip");
await page.locator('[data-action="deck.agents"]').hover();
await page.locator('.studio-deck-pop .ag-open[data-target]').first().waitFor();
await page.locator('.studio-deck-pop .ag[data-action="agent.open"][data-target]').first().waitFor();
const rows = await page.locator(".studio-deck-pop .ag").count();
check(`${scheme}: the deck lists both delegations`, rows === 2, `rows=${rows}`);
await page.locator('.studio-deck-pop .ag', { hasText: "security-review" }).locator('[data-action="agent.open"]').click();
await page.locator('.studio-deck-pop .ag', { hasText: "security-review" }).click();
await page.locator('.workbench-tab-pick', { hasText: "security-review" }).waitFor();
await page.locator(".agentrun .txt", { hasText: "rotated on every refresh" }).waitFor();
await page.waitForFunction(() => !document.querySelector(".agentrun")?.textContent.includes("## Finding"));
@@ -68,7 +68,7 @@ for (const scheme of ["light", "dark"]) {
check(`${scheme}: the record stays inside the workbench column`, box.flowRight <= box.wbRight + 1 && box.flowLeft >= box.wbLeft - 1, JSON.stringify(box));
await page.locator('[data-action="deck.agents"]').hover();
await page.locator('.studio-deck-pop .ag', { hasText: "other" }).locator('[data-action="agent.open"]').click();
await page.locator('.studio-deck-pop .ag', { hasText: "other" }).click();
await page.locator(".agent-tx").waitFor();
check(`${scheme}: a call with no run keeps the full-record dialog`, true);
await page.close();
@@ -12,6 +12,8 @@ try {
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, reducedMotion: "reduce" });
page.on("pageerror", (error) => fails.push(error.message));
await page.goto(`${base}?pref=${language}&turns=2&git=none&zoom=${zoom}`, { waitUntil: "networkidle" });
const more = page.locator(".compose .turn-more");
if (await more.isVisible()) await more.click();
const chip = page.locator(".studio-branch[data-norepo]");
await chip.waitFor();
await page.evaluate(() => document.fonts.ready);
+1 -1
View File
@@ -35,7 +35,7 @@ for (const scheme of ["light", "dark"]) {
});
const tag = `${scheme} ${width}px`;
if (width >= 760) {
check(`${tag}: the badge is on the wordmark's row, vertically centred on it`, m.shown && m.dy <= 1.5 && m.after, JSON.stringify(m));
check(`${tag}: the badge is on the wordmark's row, within the 3px optical drop the sidebar board draws`, m.shown && m.dy <= 3.5 && m.after, JSON.stringify(m));
check(`${tag}: it is an outlined uppercase badge in both themes and the text is untouched`, m.fill === "rgba(0, 0, 0, 0)" && m.border !== "0px" && m.radius !== "0px" && m.upper === "uppercase" && m.text === "studio", JSON.stringify(m));
if (!seen) seen = { left: m.left, top: m.top };
check(`${tag}: it sits in the same place whichever theme is on`, Math.abs(m.left - seen.left) <= 0.5 && Math.abs(m.top - seen.top) <= 0.5, `${m.left},${m.top} vs ${seen.left},${seen.top}`);
@@ -14,6 +14,7 @@ const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1400, height: 900 }, reducedMotion: "reduce" });
await page.addInitScript(() => localStorage.setItem("rx-nav-rail", "on"));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('.navbtn[aria-label="文件"]').first().click();
+16 -14
View File
@@ -34,21 +34,23 @@ for (const scheme of ["light", "dark"]) {
});
await page.waitForTimeout(800);
await page.mouse.move(995, 5);
const rows = await page.evaluate(() =>
[...document.querySelectorAll(".flow .call")]
const rows = await page.evaluate(() => {
const read = (kind, shell, hl) => {
const cs = getComputedStyle(shell);
return {
kind,
radius: cs.borderTopLeftRadius,
border: cs.borderTopColor,
bg: cs.backgroundColor,
head: hl ? getComputedStyle(hl).backgroundColor : "rgba(0, 0, 0, 0)",
};
};
const calls = [...document.querySelectorAll(".flow .call")]
.filter((c) => ["mem", "host", "extension"].includes(c.getAttribute("data-k")) || c.hasAttribute("data-prompt"))
.map((c) => {
const cs = getComputedStyle(c.querySelector(":scope > .c"));
const hl = c.querySelector(":scope > .c > .hl");
return {
kind: c.getAttribute("data-k"),
radius: cs.borderTopLeftRadius,
border: cs.borderTopColor,
bg: cs.backgroundColor,
head: hl ? getComputedStyle(hl).backgroundColor : "rgba(0, 0, 0, 0)",
};
}));
check(`${scheme}: four kinds of card found`, rows.length === 4, rows.map((r) => r.kind).join(","));
.map((c) => read(c.getAttribute("data-k"), c.querySelector(":scope > .c"), c.querySelector(":scope > .c > .hl")));
return calls;
});
check(`${scheme}: three kinds of card found`, rows.length === 3, rows.map((r) => r.kind).join(","));
const same = (key) => new Set(rows.map((r) => r[key])).size === 1;
check(`${scheme}: one radius`, rows.length > 0 && same("radius"), [...new Set(rows.map((r) => r.radius))].join(" / "));
check(`${scheme}: one border colour, whatever the state`, rows.length > 0 && same("border"), [...new Set(rows.map((r) => r.border))].join(" / "));
+2 -1
View File
@@ -18,6 +18,7 @@ try {
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1400, height: 900 }, reducedMotion: "reduce" });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await ctx.addInitScript(() => localStorage.setItem("rx-nav-rail", "on"));
await page.goto(`${BASE}?pref=zh&ws=2&sess=3&turns=2&changes=${CHANGES}`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('.navbtn[aria-label="文件"]').first().click();
@@ -26,7 +27,7 @@ try {
const rows = await page.locator(".workbench-change-row").count();
check(`${scheme}: ${CHANGES} changes leave the page under ${NODES_MAX} nodes`, nodes < NODES_MAX, `${nodes} nodes, ${rows} rows`);
check(`${scheme}: the list says how many it left out`, (await page.locator(".workbench-change-more").textContent())?.includes("没有显示") === true);
check(`${scheme}: the count in the head is every change`, (await page.locator(".workbench-changes .workbench-explorer-head small").first().textContent()) === String(CHANGES));
check(`${scheme}: the count in the head is every change`, (await page.locator(".workbench-changes .workbench-explorer-head small").first().textContent())?.replace(/\D/g, "") === String(CHANGES));
await page.locator(".workbench-change-more").scrollIntoViewIfNeeded();
if (shotDir) await page.screenshot({ path: `${shotDir}/${scheme}-changes-cap.png` });
await page.locator('[data-action="workbench.more-changes"]').click();
@@ -13,6 +13,7 @@ const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1400, height: 900 } });
await page.addInitScript(() => localStorage.setItem("rx-nav-rail", "on"));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('.navbtn[aria-label="文件"]').first().click();
@@ -13,6 +13,7 @@ const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1400, height: 900 } });
await page.addInitScript(() => localStorage.setItem("rx-nav-rail", "on"));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('.navbtn[aria-label="文件"]').first().click();
@@ -11,7 +11,7 @@ const check = (name, ok, detail = "") => {
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const [pref, label] of [["zh", "询问来自外部的文字"], ["en", "Ask about text from outside"]]) {
for (const [pref, label] of [["zh", "外部文字"], ["en", "Outside text"]]) {
for (const width of [1280, 420]) {
const tag = `${scheme} ${pref} ${width}px`;
const page = await browser.newPage({ viewport: { width, height: 1000 }, locale: pref === "zh" ? "zh-CN" : "en-US", colorScheme: scheme, reducedMotion: "reduce" });
@@ -19,13 +19,13 @@ for (const scheme of ["light", "dark"]) {
await page.goto(`${BASE}?pref=${pref}&turns=2`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.evaluate(() => { const b = [...document.querySelectorAll(".prefs-nav button")].find((x) => /工具与权限|Tools/.test(x.textContent)); b?.click(); });
await page.waitForSelector('[data-action="settings.section"][data-value="tools"]');
await page.evaluate(() => document.querySelector('[data-action="settings.section"][data-value="tools"]')?.click());
const sw = page.getByRole("switch", { name: label });
await sw.waitFor();
check(`${tag}: it starts off`, (await sw.getAttribute("aria-checked")) === "false");
await sw.scrollIntoViewIfNeeded();
const said = await page.evaluate(() => document.body.textContent);
const said = await page.evaluate(() => document.body.textContent + [...document.querySelectorAll("#set-external-origin [title]")].map((el) => el.title).join(" "));
check(`${tag}: it says what it does not cover and that a headless run refuses`, pref === "zh" ? said.includes("改写过或编码过的文字不在其内") && said.includes("会被拒绝而不是执行") : said.includes("reworded or encoded") && said.includes("refused instead of run"));
if (shotDir) { await page.mouse.move(width - 3, 995); await page.waitForTimeout(250); await page.locator("#set-external-origin").first().screenshot({ path: `${shotDir}/${scheme}-${pref}-${width}-off.png` }).catch(() => page.screenshot({ path: `${shotDir}/${scheme}-${pref}-${width}-off.png` })); }
await sw.click();
+1
View File
@@ -39,6 +39,7 @@ const browser = await chromium.launch();
for (const sc of SCENES) {
const page = await browser.newPage({ viewport: { width: sc.w ?? 1400, height: 900 }, locale: "zh-CN", reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.addInitScript(() => localStorage.setItem("rx-nav-rail", "on"));
await page.goto(BASE + sc.q, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(() => document.fonts.ready);
+2 -1
View File
@@ -19,6 +19,7 @@ try {
const open = async (query) => {
const page = await browser.newPage({ colorScheme: scheme, viewport: { width, height: 1000 }, locale: "zh-CN", reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.addInitScript(() => localStorage.setItem("rx-nav-rail", "on"));
await page.goto(`${BASE}?pref=zh&ws=2&sess=3&turns=2${query}`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('.navbtn[aria-label="文件"]').first().click();
@@ -59,7 +60,7 @@ try {
await one.waitForSelector('.push-card[data-phase="done"]');
check(`${tag}: a confirmed push reports it`, (await one.locator(".push-card").textContent()).includes("已把 2 个提交推送到 github.com"));
await shot(one, "pushed");
await one.getByText("提交历史").click();
if (!(await one.locator(".workbench-history").evaluate((d) => d.open))) await one.getByText("提交历史", { exact: true }).click();
await one.getByRole("button", { name: /show the commit graph/ }).click();
await one.getByRole("button", { name: /retry\.go/ }).last().click();
await one.waitForSelector(".gh-diff");
+33 -66
View File
@@ -1,54 +1,11 @@
import "../src/styles/legacy.css";
import "../src/design/design.css";
import { createRoot } from "react-dom/client";
import { boot as bootLang } from "../src/i18n";
import { track as trackWidth } from "../src/ui/viewport";
import { pushToast } from "../src/state/toasts";
import { apply as applyPack } from "../src/ui/theme";
import { mount as mountEditor } from "./syntax-mount";
import "../src/styles/tokens.css";
import "../src/styles/app.css";
import "../src/styles/dock.css";
import "../src/styles/studio.css";
import "../src/styles/changedpill.css";
import "../src/styles/askskip.css";
import "../src/styles/pastekeep.css";
import "../src/styles/ctxitems.css";
import "../src/styles/sidepanel.css";
import "../src/styles/contentmatches.css";
import "../src/styles/scrolljump.css";
import "../src/styles/chart.css";
import "../src/styles/community.css";
import "../src/styles/providerpresets.css";
import "../src/styles/sessionnotes.css";
import "../src/styles/sessionquick.css";
import "../src/styles/sidebardrop.css";
import "../src/styles/sponsor.css";
import "../src/styles/versions.css";
import "../src/styles/phonecomposer.css";
import "../src/styles/stophint.css";
import "../src/styles/dialogcenter.css";
import "../src/styles/treerows.css";
import "../src/styles/workbench-changes.css";
import "../src/styles/agentrun.css";
import "../src/styles/gitgraph.css";
import "../src/styles/tips.css";
import "../src/styles/waitnote.css";
import "../src/styles/bring.css";
import "../src/styles/peerthreads.css";
import "../src/styles/line.css";
import "../src/styles/notice.css";
import "../src/styles/peers.css";
import "../src/styles/jsontext.css";
import "../src/styles/skillpin.css";
import "../src/styles/narrow.css";
import "../src/styles/workfold.css";
import "../src/styles/workbench-launcher.css";
import "../src/styles/feedback.css";
import "../src/styles/toasts.css";
import "../src/styles/modelmenu.css";
import "../src/styles/composer-menu.css";
import "../src/styles/runsignals.css";
import "../src/styles/overview.css";
import "../src/styles/themes.css";
import { App } from "../src/ui/App";
import { MockHub } from "../src/port/mock_hub";
import { MockPort } from "../src/port/mock";
@@ -56,7 +13,7 @@ import { fromHistory } from "../src/state/session";
import { HttpError } from "../src/port/port";
import type { AgentPort, Appearance, ChipCall, HistoryMessage, McpEntry } from "../src/port/port";
import type { HistoryPage } from "../src/port/session";
import type { UpdateProgress } from "../src/port/version";
import type { UpdateProgress, VersionHub } from "../src/port/version";
import type { ThemePack } from "../src/port/look";
import type { RuntimeView, TreeWindow, TreeWorkspace } from "../src/port/hub";
import type { WireEvent } from "../src/port/wire";
@@ -218,8 +175,17 @@ class BenchPort extends MockPort {
return query.has("update") ? "notify" : super.updateMode();
}
async versions() {
async versions(): Promise<VersionHub> {
if (query.has("update")) return { current: "2.42.0", pinned: "", stalePin: false, latest: "2.43.0", newer: true, versions: [] };
if (query.has("versionlayout")) {
return {
current: "2.56.0", pinned: "", stalePin: false, latest: "2.57.0", newer: true,
versions: ["2.57.0", "2.56.1", "2.56.0", "2.55.0", "2.54.0", "2.53.1"].map((version, i) => ({
version, tag: `studio-v${version}`, publishedAt: new Date(Date.now() - i * 86400000).toISOString(),
hasNotes: i !== 4, current: i === 2, older: i > 2,
})),
};
}
if (!query.has("versions")) return super.versions();
const names = ["2.38.0", "2.37.0", "2.36.0", "2.35.0", "2.34.0", "2.33.0", "2.32.0", "2.31.0", "2.30.0", "2.29.0"];
return {
@@ -307,12 +273,6 @@ class BenchPort extends MockPort {
}
private settingCardSent = false;
private receiptSent = false;
async receiptSettings() {
const read = await super.receiptSettings();
return query.has("receipt") ? { ...read, show: true } : read;
}
private meterSent = false;
subscribe(on: (e: WireEvent) => void) {
@@ -328,14 +288,6 @@ class BenchPort extends MockPort {
}
}, 500);
}
if (query.has("receipt") && !this.receiptSent) {
this.receiptSent = true;
const script = "cd \"C:/Users/me/work\" && cat > \"$TMPDIR/check/verify_006.py\" <<'PYEOF'\n" + "import os, re, sys, unittest\nclass T(unittest.TestCase):\n def test_a(self):\n self.assertEqual(1, 1)\n".repeat(18) + "PYEOF\npython -m unittest verify_006 2>&1 | tail -12";
setTimeout(() => {
this.feed({ kind: "turn_started", msgIndex: 9 } as unknown as WireEvent);
this.feed({ kind: "turn_done", receipt: { saysSomething: true, severity: "warn", gaps: [{ kind: "failed_verification", detail: script }, { kind: "inconclusive_verification", detail: "go test ./internal/... 2>&1 | tail -5" }] } } as unknown as WireEvent);
}, 500);
}
const card = query.get("settingcard");
if (card && !this.settingCardSent) {
this.settingCardSent = true;
@@ -447,10 +399,22 @@ class BenchPort extends MockPort {
return super.switchBranch(name);
}
async versionNotes(version: string) {
if (!query.has("versionlayout")) return super.versionNotes(version);
return { version, markdown: "# Release notes\n\nDownload and rollback improvements.", cached: false };
}
async goToVersion() {}
onUpdateProgress(cb?: (p: UpdateProgress) => void) {
if (cb && query.has("update")) queueMicrotask(() => cb({ version: "2.43.0", phase: "ready", received: 0, total: 0 }));
const phase = query.get("versionlayout");
if (cb && (phase === "downloading" || phase === "verifying" || phase === "ready")) {
queueMicrotask(() => cb({
version: query.has("rollback") ? "2.55.0" : "2.57.0", phase,
received: 178_900_000, total: 183_900_000,
}));
}
return () => {};
}
@@ -524,10 +488,13 @@ class BenchHub extends MockHub {
// ?search=1 answers every query with two conversations that say it.
async searchHistory(q: string) {
if (!query.has("search")) return super.searchHistory(q);
return [
{ path: "/sessions/mock.jsonl", id: "mock", snippet: `…上周讨论过要不要把 ${q} 的轮换放进发布流程,结论是每月一次…`, role: "assistant", messageIndex: 4, score: 2 },
{ path: "/sessions/older.jsonl", id: "older", snippet: `怎么把 ${q} 配到预发环境里?`, role: "user", messageIndex: 0, score: 1 },
];
return {
hits: [
{ path: "/sessions/mock.jsonl", id: "mock", snippet: `…上周讨论过要不要把 ${q} 的轮换放进发布流程,结论是每月一次…`, role: "assistant", messageIndex: 4, score: 2 },
{ path: "/sessions/older.jsonl", id: "older", snippet: `怎么把 ${q} 配到预发环境里?`, role: "user", messageIndex: 0, score: 1 },
],
index: { state: "ready", indexed: 2, total: 2, failed: 0, partial: false },
};
}
private readonly settingsPort = new BenchPort();
@@ -672,7 +639,7 @@ function settingCardFrame(kind: string): WireEvent {
return { kind: "notice", level: "info", code: "setting_open", text: "助手需要改动 permissions.mode,只有你本人能改。", detail: "permissions.mode" } as WireEvent;
}
const elevated = kind === "elevated";
const id = elevated ? "peers.dispatch" : "ui.show_receipt";
const id = elevated ? "peers.dispatch" : "ui.show_changed_files";
return {
kind: "approval_request",
approval: {
@@ -38,6 +38,8 @@ try {
check(`${context}: one hover ground across the row`, grounds.size === 1, [...grounds].join(" / "));
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(url.href, { waitUntil: "networkidle" });
const more = page.locator(".compose .turn-more");
if (await more.isVisible()) await more.click();
const chip = page.locator(turns ? ".compose .studio-branch" : ".compose .worktree-trigger").first();
await chip.click();
check(`${context}: a narrow-pane pointer click opens the workspace menu`, await chip.getAttribute("aria-expanded") === "true");
+3 -1
View File
@@ -6,9 +6,11 @@ import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
// Names the demo data itself carries, and names that are written in Chinese on purpose.
const FIXTURE = [
/并行会话演示/, /上一次的会话/, /站点改版/, /数据管线/, /跑一遍评测/, /^中文$/, /^做游戏的小鱼$/, /^DeepSeek-Reasonix官方群$/,
/并行会话演示/, /上一次的会话/, /站点改版/, /数据管线/, /跑一遍评测/, /^中文$/, /^简体中文$/,/^做游戏的小鱼$/, /^DeepSeek-Reasonix官方群$/,
/缓存命中率为什么会掉/, /Co-Authored-By/, /Generated with/, /^回复用中文$/, /^跟着用户每条消息的语言走$/, /v2 是从零重写的 Go 内核/,
/没有 web,桌面端重做/, /问题里提到了 main-v2/, /构建用 -tags legacy/, /^迁移前的构建方式$/,
/^发布流程$/, /^工作流$/, /^架构$/, /^用户偏好$/, /^发版前先确认合并队列$/, /^队列里有严重修复就先合进同一版$/, /^发版前先扫一遍待合并的修复/,
/^每天最多发一个版本$/, /^热修只在完全不可用时例外$/, /^北京日历日最多发一个版本/, /^测试都在 Windows 机器上跑$/, /^Mac 只改代码$/, /^所有 Go\/前端测试和 lint/,
];
const fails = [];
const browser = await chromium.launch();
+8 -8
View File
@@ -18,12 +18,12 @@ for (const scheme of ["light", "dark"]) {
await page.goto(`${BASE}?jobs=3&jobnames=1`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(800);
await page.evaluate(() => [...document.querySelectorAll("button")].find((b) => /后台任务/.test(b.textContent))?.click());
await page.waitForSelector(".jobs .job .cmd");
await page.locator('[data-action="deck.jobs"]').click();
await page.waitForSelector(".job-deck-name");
const tag = `${scheme} ${width}px`;
const shown = await page.evaluate(() => [...document.querySelectorAll(".jobs .job .cmd")].map((e) => e.textContent));
check(`${tag}: the list shows the names, not the commands`, shown.length === 3 && shown.every((t) => !/go test|npm run/.test(t)), shown.join(" | "));
await page.locator(".jobs .job .cmd").first().hover();
const shown = await page.evaluate(() => [...document.querySelectorAll(".job-deck-name")].map((e) => e.textContent));
check(`${tag}: the list shows the names, not the commands`, shown.length >= 3 && shown.every((t) => !/go test|npm run/.test(t)), shown.join(" | "));
await page.locator(".job-deck-name").first().hover();
await page.waitForSelector(".job-tip");
const tip = await page.evaluate(() => {
const el = document.querySelector(".job-tip");
@@ -35,12 +35,12 @@ for (const scheme of ["light", "dark"]) {
await page.mouse.move(width - 5, 5);
await page.waitForTimeout(200);
check(`${tag}: it closes when the pointer leaves`, (await page.locator(".job-tip").count()) === 0);
await page.locator(".jobs .job .cmd").nth(1).focus();
await page.locator(".job-deck-name").nth(1).focus();
await page.waitForSelector(".job-tip");
check(`${tag}: focus from the keyboard opens it too`, /内核测试/.test(await page.locator(".job-tip .jt-name").textContent()));
await page.keyboard.press("Escape");
await page.waitForTimeout(150);
check(`${tag}: Escape closes it`, (await page.locator(".job-tip").count()) === 0);
const closed = await page.locator(".job-tip").waitFor({ state: "detached", timeout: 2000 }).then(() => true, () => false);
check(`${tag}: Escape closes it`, closed);
await ctx.close();
}
}
+7 -7
View File
@@ -1,5 +1,5 @@
// An allowed guardian review at low risk and a remembered fact are Lines, not cards: one row over a hairline with no box.
// A refusal, or an allow that carried real risk, keeps the card.
// An allowed guardian review at low risk is a Line, not a card: one row over a hairline with no box.
// A refusal, or an allow that carried real risk, keeps its note, and a remembered fact is a note that carries its take-back.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
@@ -36,15 +36,15 @@ for (const scheme of ["light", "dark"]) {
lines: rows.length,
boxes: rows.map(box),
radius: rows.map((r) => getComputedStyle(r).borderTopLeftRadius),
cards: [...document.querySelectorAll('.flow .call[data-k="host"]')].length,
forget: !!document.querySelector('.flow .line-row [data-action="memory.forget"]'),
cards: [...document.querySelectorAll(".flow .note")].filter((n) => n.textContent.includes("守卫复核")).length,
forget: !!document.querySelector('.flow .note [data-action="memory.forget"]'),
oldMem: document.querySelectorAll('.flow .call[data-k="mem"]').length,
};
});
check(`${scheme}: the remembered fact and the low-risk allow are rows`, m.lines === 2, `${m.lines} rows`);
check(`${scheme}: the low-risk allow is a row`, m.lines === 1, `${m.lines} rows`);
check(`${scheme}: rows carry no box`, m.lines > 0 && m.boxes.every((b) => b === "0px,0px,0px") && m.radius.every((r) => r === "0px"), JSON.stringify(m));
check(`${scheme}: the high-risk allow and the refusal keep their cards`, m.cards === 2, `${m.cards} cards`);
check(`${scheme}: the take-back sits in the row, and no memory card remains`, m.forget && m.oldMem === 0);
check(`${scheme}: the high-risk allow and the refusal keep their notes`, m.cards === 2, `${m.cards} cards`);
check(`${scheme}: the remembered fact keeps its take-back, and no memory card remains`, m.forget && m.oldMem === 0);
await ctx.close();
}
await browser.close();
+2 -1
View File
@@ -94,7 +94,8 @@ for (const scheme of ["light", "dark"]) {
}
if (width === 1280) {
await page.locator('[data-action="extensions.add"]').first().click();
await page.locator('.ext-add-btn[data-action="extensions.add"]').first().click();
await page.locator('[role="menuitem"][data-value="source"]').click();
const source = page.locator("textarea, input").filter({ hasNot: page.locator("[aria-label]") }).last();
await page.locator('textarea[placeholder*="github.com"], input[placeholder*="github.com"]').first().fill("https://github.com/acme/claude-code mod git-guard");
await page.locator('[data-action="extensions.inspect"]').first().click();
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 1300, height: 900 } });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(".model-picker");
await page.locator(".model-picker").first().click();
await page.waitForSelector(".studio-model-menu .studio-item-line");
const sizes = await page.evaluate(() => {
const lines = [...document.querySelectorAll(".studio-model-menu .studio-item-line")].filter((line) => line.querySelector(".studio-item-badge"));
for (const line of lines) line.querySelector(".lb").textContent = "workbuddy/deepseek-v4.1-flash-preview-extended";
return lines.map((line) => {
const badge = line.querySelector(".studio-item-badge").getBoundingClientRect();
return { width: Math.round(badge.width), height: Math.round(badge.height) };
});
});
assert.ok(sizes.length > 0, "the menu has a model that reads images");
for (const size of sizes) assert.ok(size.height <= 20, `a long model name leaves the badge on one line (${JSON.stringify(size)})`);
assert.deepEqual(errors, []);
console.log(`PASS model menu badge stays on one line beside a long name (${sizes.length} rows)`);
} finally {
await browser.close();
}
+2 -1
View File
@@ -18,7 +18,8 @@ try {
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 20000 });
await page.getByRole("button", { name: "设置", exact: true }).click();
await page.getByText("模型服务", { exact: true }).first().click();
await page.locator('[role="tab"][title="模型"]').click();
await page.locator('[data-action="settings.open-page"][data-target="providers"]').click();
await page.getByText("myrelay", { exact: true }).first().click();
await page.getByText("从服务商读取可用模型").first().click();
await page.waitForSelector(".mrows .mline");
+2 -1
View File
@@ -23,7 +23,8 @@ for (const scheme of ["light", "dark"]) {
await page.waitForTimeout(500);
await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => /备份原文件并修复/.test(x.textContent))?.click());
await page.waitForTimeout(400);
await page.evaluate(() => [...document.querySelectorAll('[data-action="settings.section"]')].find((x) => x.textContent.includes("模型服务"))?.click());
await page.evaluate(() => document.querySelector('[role="tab"][title="模型"]')?.click());
await page.evaluate(() => document.querySelector('[data-action="settings.open-page"][data-target="providers"]')?.click());
await page.waitForSelector(".pdetail", { state: "attached" });
await page.evaluate(() => [...document.querySelectorAll("button,li,a")].find((x) => /myrelay-work/.test(x.textContent) && x.textContent.length < 80)?.click());
await page.waitForSelector(".pdetail .mlhead", { state: "attached" });
+5
View File
@@ -139,6 +139,11 @@ for (const scheme of ["light", "dark"]) {
await page.waitForSelector(".app");
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(500);
const more = page.locator(".compose .turn-more");
if (await more.isVisible()) {
await more.click();
await page.waitForTimeout(300);
}
const tag = `${scheme} ${width}px zoom ${zoom}`;
const m = await probe(page);
check(`${tag}: title bar controls do not overlap`, m.bar.overlaps.length === 0, m.bar.overlaps.join(", "));
Loaded 100 of 2163 files, more files were not shown because too many files have changed in this diff. Show more