feat(execution): the session continuation surface, and the arms' checks read data - #70
Merged
Merged
Conversation
Fresh slice on main now that #61 is merged (branch feat/ooo-session-continuation-surface). The fusion mechanism is landed but nothing in the product decides to reuse a session: the only caller is the live arm, and it keys its runners off the spec's own session id, which is an eval artifact. This proposes the product path shape with no new tool: resolution and use both go through the board, which already records the session that wrote each entry (source_session_id), already keys delivery receipts on (entry_id, session_id), and already fences managed writes to a registered run. Legality is unchanged: sharedSessionLegal stays the only rule, no bound, no switch. Status is proposed: the resolution helper, the runner keyed by board session, the tests, the registry row and the field trial follow on this branch.
…solves it Correcting this record's own wording: it is not a resolution module. The identity already exists in three places nothing has to derive - the caller's own session id (ctx.sessionManager.getSessionId()), the entry's source_session_id, and the managed-write fence on a registered run - so the change is keying the runner by that identity and recording the move on the board, not adding a helper or a column. The eval arm's sessions: [[...]] and session: stay in the spec, where a measurement artifact belongs.
…deterministic pass Two shape corrections to this proposal. The declaration is not a tool and not a schema change: like the memory=<id> pointers a board entry already carries, an entry may carry one fenced nmg: block whose body is JSON - the parameters of the call that wrote it - and a reader that does not understand it reads the prose unchanged, with rendering the block optional. And the thing that turns those blocks into a session grouping plus the next move is a compiler-like pass over the board: prose through untouched, no model, recomputed at each boundary rather than cached, living beside the board on the daemon side so CLI, extension and driver see one layout instead of three.
…un fact Replacing the fenced-block-in-the-entry idea with the simpler shape: the call that already exists carries one dedicated JSON field, so nothing is parsed out of prose and no text convention is invented; the computed decision is written as a run fact in the table that already exists for it (task_run_facts, today holding entry-bound and run-cancelled), which gives it a sequence number and lets the existing as-of-sequence read replay it. The pass stays deterministic and model-free, and showing the decision anywhere is optional.
First step of the board-side continuation: src/integration/ooo-session-facts.ts owns one fact kind (session-move) and the one write that appends it, declared next to that write the way task-coordinator.ts declares its own kinds - one home for the vocabulary, because the write and the read must agree and neither may guess the string. It carries the move nextSessionMove already computes (admit a unit, or close the session naming the condition) as the fact's JSON payload, and reads it back defensively: a payload that is absent or is not a move this module wrote is skipped rather than trusted. Nothing here decides - the decision stays nextSessionMove's pure function of the plan and the facts. The new file is claimed by the ooo-execution route, which the guard test in tests/tools/repo-context.test.ts requires (it lists files one by one, so an unclaimed file fails it). Verified: agent:context selects ooo-execution for the new file, the guard test passes 22/22, npm run agent:context:check is valid, lsp_diagnostics reports 0 diagnostics.
…tself Four checks on the record src/integration/ooo-session-facts.ts keeps: an admit reads back as the same admit, a close carries the condition that closed the session through the log and back, the same move written twice is one fact (the second write reports recorded:false and the first sequence, because the store keys a fact on run, kind, task, attempt), and a log with no move reads as empty while a payload under this module's kind that this module did not write (an admit without a unit) is skipped rather than trusted. Teeth, named mutant: changing parseSessionMove to accept an admit without a string unit fails exactly the fourth test and leaves the other three green - the check is not satisfied by any payload of the right shape. Restoring the original leaves all four green and the source byte-identical.
decideSessionMove reads the run's facts, computes the move, and appends it under this module's kind. It decides nothing of its own: the move is nextSessionMove's, and its one added input is the fact that can end a session early - a cancelled run admits nothing further whatever the plan says - which is re-read from the run's log rather than taken from the caller, the way the managed-write fence reads its two refusals. Three more checks ride on that: the decision and the log agree (the recorded move is the move that was returned), a session that cannot continue closes by name and never by guessing (bound reached, or nothing legal on offer), and a cancelled run admits nothing. One semantics pinned by a test rather than left implicit: a move belongs to a boundary, and a boundary is a unit and an attempt - the same fact identity the store keys on. So one boundary is one move however often the caller asks, and the second answer is the first one rather than a second fact; a caller that decides again after the facts changed must say it is a new attempt, or the second decision is not recorded and the log stops describing what the code did.
…he harness Grouping and reuse are the mechanism, so the key one board session's runner is held under is computed in the shared layer from what the board already holds - the run and the session that owns its entries - and from nothing else. Two callers that see the same run and same session therefore agree without talking to each other, and a replay computes the key again rather than remembering it. A missing part refuses instead of composing a key that could collide with a real one. This is the first piece of moving the session mechanism out of the harness: today createPiSessionRunner is defined in .pi/extensions/nmg/ooo-execution.ts and the eval drivers import it from there, so measurement borrows its mechanism from one harness. The pure parts (unit state, the completion policy, text-to-artifact, and this key) belong in src/integration/; only the code that actually opens a pi session belongs in an adapter.
… the harness becomes an adapter Which unit runs under which session, what a unit's state is, the completion policy, the artifact contract, the snapshot/text conversions and the PiSessionRunner contract are all decided the same way whoever is running, so they now live in src/integration/ooo-session-mechanism.ts. What is left in .pi/extensions/nmg/ooo-execution.ts is the adapter: it is where createAgentSession, ModelRuntime, defineTool and the tools built from them live, and it imports the mechanism. 797 lines became 491 adapter plus 449 shared. The split is mechanical, not editorial: a block belongs to the adapter when it mentions an imported pi or typebox name outside a comment, and that mark propagates through references - a block that calls a block which talks to pi also talks to pi. The report refused the split when a shared block reached an adapter block, so nothing here compiles only by accident of the move. Consumers were repointed by hand: the verification test takes the artifact contract and the completion policy from the shared layer and resourceLoader from the adapter, the speculation pilot takes patchSessionInput from the shared layer and createPiSessionRunner from the adapter, and live-pi takes the PiRun type from the shared layer. The eval drivers still reach the adapter for the model call, which is what an adapter is for, but they no longer borrow the mechanism from one harness. Verified: npm run check clean, the affected suites 30/30, the repository-context guard accepts the new file and npm run agent:context:check is valid. The hidden-features registry row now says which half is shared and which half is the adapter.
…der, and the move that put it there openUnitSession is the caller's side of the mechanism: it computes the key the unit's runner is held under from the run and the session identity the caller already holds, decides the boundary through decideSessionMove, and returns both. A caller that is not the extension needs no other session surface: it supplies the run, its own session, and where that session stands, and the board decides the rest and records it under session-move. The test pins the three consequences that matter to a caller: the same run and session give the same key and the first answer stands (a second ask reports recorded:false), a different session is a different runner, and both decisions are in the log with their own sequences.
…hat asked The first version of this test asserted that two sessions deciding the same unit and attempt leave two moves. They do not: a fact is keyed on (run, kind, task, attempt), so the session that asked is not part of the move's identity. That is now what the test says - a second session asking about the same boundary is the same fact and gets the first answer, and a caller that would decide the boundary again must say it is a new attempt. The earlier commit carries the assertion that was wrong; this one corrects it, and the check was green only after this change.
…chanism owns
The move put the mechanism in src/integration/ooo-session-mechanism.ts and left the pi file as an adapter. Nothing checked the rule: evals/** has no tsconfig coverage, so tsc never reads the drivers, and lint does not know which module owns which name. It broke exactly there - plan-driver.ts still got patchSessionInput from the adapter, the live fused run failed with "patchSessionInput is not a function", and only the run itself said so.
The check reads the names the shared module exports and looks for them in every import a driver makes of the adapter, in all three forms a driver uses: static named imports, awaited dynamic imports with destructuring, and the type-position import("path").Name. It refuses to pass on an empty export list, which would be a guard that agrees with anything.
plan-driver.ts now takes patchSessionInput and the PiSessionRunner type from the shared layer and keeps only createPiSessionRunner from the adapter.
Verified: the guard fails on the exact two names before the fix and passes after it; 41/41 in the four affected suites.
…ould not be measured Two arms one declaration apart over the pipeline fixture, run live. The control arm passed: 4/4 units accepted, the composed parent accepted, 24849 ms wall, 30000 tokens, 17408 cache read, 0 failures. The fusion arm produced no measurement at all: it fails on the first unit with stopReason=error, turns=4, reads=1 and no artifact, on the only path it takes - piSessionWorker with --session-runner, which creates the first runner with chain: true. The control arm's worker goes through executePiPatch (chain: false) and works with the same provider and model. The same spec run at 0296466, the commit before the session mechanism moved to the shared layer, fails identically, so the failure predates the move and is not caused by it. The specs, both results and that pre-move run are in this directory, along with the script that builds the two specs and refuses a pair that differs in more than the arm's declaration.
… admitted A single attempt gets a per-unit literal union for the artifact's conclusion, so the model cannot answer with a kind that unit does not admit. A fused session cannot: the tool surface is fixed when the session is created, so the shared envelope loosens the conclusion to a plain string and refuses an invented kind afterwards. patchPrompt deliberately does not repeat the schema in the prompt, which is right when the schema carries the rule and wrong the moment it stops carrying it - and in a chain it stopped. The model then guessed, the envelope refused, and the retry spent the turn budget: the live fused arm was aborted at turn 4 of a declared 3, which is why it produced no measurement at all. patchSessionInput now takes looseConclusion and, when it is set, names the admitted kinds in the prompt, one sentence appended exactly where the check and pushback notes are. The runner refuses a session and an input whose flags disagree, before it creates a runtime, because the cost of the disagreement is a wrong guess per unit paid silently. The bounded-contract error now prints turns and reads against their limits, which is what made this failure hard to read: the abort said stopReason=error and nothing about which budget it had passed. Verified offline: the strict input does not name the kinds, the loosened one does and says a kind outside the list is refused, and both disagreement directions are refused. npm run check clean and test:product 1507/1507.
…s its unit may use Two rules the fixed surface pushes out of the schema, and both cost the live fused arm its turn budget. First: a chain registers the tools of every unit it will run, so a unit with no check is still shown run_check - the model called it, was told the unit has no check, and spent a turn finding out. Second, and this is what actually ended the attempt: the envelope refuses a submission that carries files and a conclusion at once, and no schema can express that exclusivity, so the prompt is the only place it can be said. The trace that named it, from the instrument added here: calls=read_snapshot,run_check,submit_artifact, artifact=no artifact, last refusal: a patch carries files only; it cannot also carry a conclusion. patchSessionInput now appends, for a loosened surface only, which tools this unit may call and that the two answer channels are exclusive, alongside the admitted conclusion kinds. The strict path is byte-identical to patchPrompt plus its existing notes, which is asserted in the test, so the control arm already recorded still describes this code. The instrument is part of the fix: the box records the tools a unit called and the envelope's last refusal, and the bounded-contract error prints turns and reads against their limits. Before that, an aborted unit said stopReason=error and nothing else, and finding out why took three paid runs. Verified live: the fused arm now completes, 4/4 units accepted with the composed parent accepted. npm run check clean, test:product 1508/1508.
…kens, 35% less wall time Both arms accepted all four units and the composed parent. Fusion spent 34601 tokens against the control's 30000 (1.15x) and 24064 cache reads against 17408 (1.38x), while wall time fell from 24849 ms to 16054 ms (0.65x) over two fused sessions instead of four fresh ones. Every per-unit delta is positive, so on this fixture a continued session does not spend less on its next unit; the warm context is a longer context. The README also records what it cost to measure: the fused arm first died on its first unit at turn 4 of a declared 3, and the two defects behind that - a chain showing a unit a check tool it has no check for, and an envelope rule about the two answer channels being exclusive that no schema can express - were only visible after the error line was made to print turns and reads against their limits, the tools called, and the last refusal. One rep per arm, so this is a pair and not a rate.
…hole run managedWriteRefusal matched the cancellation fact by kind alone, so cancelling one task of a run refused every other task's lifecycle writes in it - cancelRun has taken a taskId since it was written, and the fence ignored it. The same predicate was copied into the session decision, where a cancel for one unit closed another unit's session. The rule now has one home: taskCancellation(store, runId, taskId) returns the cancellation that applies to a task, with the run-level one carrying the schema's empty task id and applying to everything. managedWriteRefusal takes the task it is asked about - coordinatedBoardWrite resolves it from the entry's own binding, bindRunEntry already has it, and freezeRunPlan asks about the run, which is what a plan freeze is - and the session decision asks about the unit's task. A caller that names no task now hears only about a run-level cancellation. Verified: cancelling T1 refuses T1's entry and leaves T2 claimable by the coordinated path, with the reason line naming the task that was cancelled; a run-level cancellation still closes both; and the session test shows another task's cancellation no longer ending this unit's session. npm run check clean, test:product 1510/1510.
…arry Three readings had outrun their samples. The D arm's 1.9 s wall saving was attributed to session startup and called "wider than either arm's own spread". The numbers deny both: the gap between the medians is 1.9 s and each arm's own spread is 2.2 s, so the difference is narrower than the noise it claims to exceed and does not separate a startup term from ordinary run-to-run model time. The term stays unmeasured until the cap experiment, which finds it plan-dependent. The cap experiment's knee at two units per session was written as a declared policy and restated as one in the proposal that depends on it. Every cell is two runs, and fewer sessions is not the same quantity as a shorter parent task - over one slot, fusing removes parallelism. It is a hypothesis for the A-D comparison now, in both languages. tokens - cache read was called fresh input. Output tokens are billed too and no subtraction of cache reads removes them, so it is a lower bound; the per-unit "real" second-unit saving and the mechanism that explained it are marked as directions, and the mechanism half that was repaired today is cross-referenced so a re-run is not expected to reproduce it.
… corrected readings tokens - cache read was called a lower bound on billed input. It is not: tokens counts input and output together, so the remainder is the tokens not served from cache - uncached input plus every output token - and the reports do not say whether the cache figure is nested inside the total. Pricing needs the three recorded apart, which this experiment did not do. Corrected in the fusion planning document, the decision record that quotes it and the proposal that leans on it, and the archive README now carries the same note for the column it introduced. A spread wider than a difference was also read as the difference not existing. It says the sample cannot resolve the effect; the D arm's two-rep spreads (2.2 s) exceeding its 1.9 s median gap is a reason to run more reps, not evidence that fusion saves nothing. P6 of the arm plan asks which cells of the A-D comparison already exist and finds the answer in the archived specs: the cap experiment is a controlled same-task comparison, its three specs differing by exactly one field (fusion.unitsPerSession), one instrument, two reps per cell, with per-run values and spreads published there for the first time - the cap1-to-cap2 saving (8.2-9.1 s) is an order of magnitude wider than the noise inside the cells, which is why the 2-unit D arm's weak result and this experiment's strong one are both true. It names the cells that are missing, the hypothesis each would distinguish, their measured cost (85 k for the recommended pair of extra reps), and the stop rule. It also records what the archive does not hold: the A, B and C arms were not rescued, so for the coarse and slot arms there is a summary, not a sample - the archive README's 'every sample' claim is corrected in a dated note.
The reviewer's point was that the research instrument's tables sit on a different path from the product's run facility, and the answer is not to join them - it is to say what each side's tests prove. The ledger now carries that boundary. Two bases, no shared storage, no transferred proofs. The product path (the store's task_runs/task_run_facts, the coordinator's fence and cancellation rule, the session mechanism, the harness adapter) is proven by ten named files. The research instrument (BoardAdmission and the ooo_probe_* family with the task-semantics modules, driven by evals/ooo-execution) is proven by the twenty-seven test files whose static imports reach ooo-board.ts: sixteen under tests/integration, eleven under evals. Five of those sixteen are named in the section because their file names read like the product's own board - ooo-managed-fence, ooo-transition-atomicity, ooo-run-namespace, ooo-task-tables, ooo-read-paths-agree - and each constructs BoardAdmission itself. Reachability is a screen, not a verdict (a fixture builder or a type import reaches the same module while the assertion stays on product code), so the rule is stated as a rule: an OoO claim may cite only the product-side proofs, and the two rg lines that reproduce the screen are in the section.
…rvives, the token reading does not Bought to give the A-D cells a range instead of a two-point gap: one more rep each for bounds 1 and 2 of the four-unit fine plan, same instrument, same single variable (fusion.unitsPerSession). Spent 80 404 tokens: 33 941 for cap 1, 35 444 for cap 2, and 11 019 for one refused run kept as evidence. Wall clock: 25 270 / 26 186 / 27 053 ms at cap 1 against 17 427 / 17 735 / 17 998 ms at cap 2. The median saving is 8 451 ms against within-cell spreads of 1 783 and 571 ms, so it is about five times the larger spread and the saving is a rate, not one lucky pair. Everything the review asked for on this axis is now measured on one parent task with the same configuration. Tokens: the opposite. Cap 1's own token spread is 11 946, wider than its 7 789-token median gap to cap 2, and the cache-read share of tokens runs from 0.607 to 0.847 inside a single cell - so '80-84 % of tokens are cache reads' was a two-rep artefact and no token-direction claim survives at this rep count. Per-unit tokens across the thirty-two stored units span 7 105 to 19 163. Two facts from executing it. The command had to be guessed and one run was refused: without --session-runner the driver names the session it cannot continue, records it in incomplete and stops after the first unit, which is the guard working and is archived rather than deleted. And the fused cell's third rep ran with a newer chain prompt than its first two (the exclusivity and admitted-tools lines added earlier the same day), so those three reps are not one instrument version - the post-fix rep is the fastest and lowest-token of the three, which says the change did not hurt the cell, not that it helped it. aggregate-3rep.json recomputes every value from the stored reports; aggregate.json keeps the original two-rep reading. The fusion planning document, the decision record that quoted the two-rep medians, the ledger's F4 line and P6 of the arm plan all now say what the third rep settled and what it did not.
…s are replaced, and the reps stay paired The review found the report still carrying three withdrawn readings, an aggregate whose columns could not be paired with the runs they came from, and a comparability claim that its own text denied. All of it is document work. The benefit is now stated where it is measured: the four-unit fine plan's cap 1 to cap 2 saves 8 451 ms against within-cell spreads of 1 783 and 571 ms, 4.2 s per avoided session, and the two-rep pair alone separates (26.2-27.1 s against 17.7-18.0 s) - so the effect is not something the third rep's newer prompt introduced, and the D arm's own weak sample is about that sample, not about fusion. That last sentence is also in the arms record now, so its D arm section cannot be read as 'fusion unproven'. The withdrawn readings are replaced, not annotated around: the front half of the fusion planning document no longer says the D arm measured ~1 900 ms of startup with tokens flat or that fusing costs about 15 % more fresh input, and the sentence that used the D arm's delta to estimate and then cited that same delta as the ceiling's credential is now labelled an arithmetic check, with the independent test beside it - the same tool predicts 3 800 ms on the fine plan and the runs measure 8 451, a factor-of-two disagreement that is the useful result. The implemented decision record keeps its old wording only inside a dated correction block, and the ledger's startup-term range is corrected to 3.2-4.2 s. Pairing: aggregate-3rep.json now carries one object per rep, with sorted arrays named as such and medians computed from the objects; both tables that quoted three sorted columns positionally are now one row per run. Provenance: the reports do not record an instrument commit, so the archive states what can be established from the outside - reps 1 and 2 at 06:54-06:56Z, before the chain-prompt fix dated 12:11:24Z; the third reps and the refused run at 12:32-12:33Z from a clean tree at 4b0ba09 - and P6 lists 'the driver writes its own commit and a prompt digest' as the free item that would remove the need for that prose. Two over-strong claims are gone with it: that the token columns settle nothing at any affordable rep count (three reps are not enough, and the reason is their spread) and that '1.2 s exceeds 1.9 s' meant anything beyond that sample.
…usage split The cap experiment's cost question could not be answered from its reports: they recorded a token total and cache reads, and a total cannot be taken apart again. The provider reports more, so the harness now sums input, output, cache read, cache write and the provider's own price, and the driver records them per unit beside the totals - a cost claim no longer has to be argued from arithmetic that cannot be done. Two things came with it. A unit's report carries a promptDigest of the input its session was given, and every run carries instrument.commit, read from git at run time, so two runs that agree on a spec can still be told apart by what they actually ran. That was the review's point that the version basis lived only in prose, and the cap reports from earlier the same day name no commit at all. Also fixed, both pre-existing: four unused imports that lint had been failing on since the harness-to-shared move and the chain-prompt fix - npm run check does not run eslint, so only verify:static saw them, and those two passes ran check alone; and one mutation anchor, stale since the bound comparison moved into nextSessionMove, now breaks the driver's hand-off of the declared bound instead, which is the same invariant pinned in a target that exists (11 of 11 caught, restored byte-identically).
…in surface costs Reading the driver to plan these runs turned up what the earlier readings had missed: all three cap specs declare fusion, so every cap cell runs the chain surface - one session per slot, the loosened schema, the chain prompt - even at bound 1. cap 1 is therefore the fused arms' same-surface control, not the unfused B cell, and the granularity comparison had no plain-path arm at all. Three cells were run on the plain path with the same fixture, worker, envelope and parent check: A (coarse, 1 unit, slots 1) 9 726 ms / 9 018 tokens, B (fine, 4 units, slots 1) 21 885 ms / 29 962, and C (the same spec at 2 slots) 18 565 ms / 30 674. Every unit and every parent check accepted, slotsUsed as asked, 69 654 tokens for the three. They are the first reports that name their own commit and carry the usage split and a prompt digest; the totals decompose exactly (A: 2 377 + 1 521 + 5 120 = 9 018) and the provider's price comes with them. Two readings follow. Declaring fusion at a bound of one, which fuses nothing, costs 4 301 ms and 15 520 tokens more than the plain path for the same plan (26 186 / 45 482 against 21 885 / 29 962, most of the token difference cache reads), so the chain surface is not free. And fusion still wins on wall clock against both baselines: cap 2's 17 735 ms median is 4 150 ms below plain B and 8 451 ms below cap 1, while spending 7 731 tokens more than B. Pricing that difference is the one purchase left, because no cap report carries the split.
… end The program was being run one purchase at a time, and every report ended by naming the next one, which is how a plan turns into a treadmill. What was missing was not a cell but the plan: which cells exist in total, what question each one answers, which decision that question informs, and the rule for comparing them. So P6 is now the plan, and it is the one home of the measurement rules - one declared field per pair, the grade a reading gets (single observation, pair, rate), the spread rule for calling a difference separated, same instrument or say so, cost from the provider rather than from tokens, pair aggregates per rep. The cells are enumerated with their questions, the deliberate non-cells are named with reasons, and the end state is written out: Q1, Q3 and Q4 answered, Q5 a hypothesis, Q6 and Q7 closed unmeasured with their reasons, and Q2/Q3's pair-grade confirmation priced as an option (about 60k) rather than left as a to-do. The tables are read from the stored reports by a script that refuses a missing report or one whose token total does not equal its four parts, and its computed reading is archived as matrix.json. The archive README and the design docs now point at the plan instead of restating its comparison rules.
Bought the one cell pair the plan had priced, and only it: B's and C's second rep (30 178 and 30 472 tokens). Both accepted every unit and the parent check, C ran its two slots, and both reports name the commit they ran from. The readings move as the grade rule said they would. A vs B separates at single-observation grade (12 853 ms against a 6 940 ms threshold). B vs C separates at pair grade (3 628 ms against 2 776 ms) - and the second slot came out both faster and cheaper in this pair, C's median price 0.001826 against B's 0.002106, which the token column alone would have hidden (C spends 503 more tokens, on more cache reads and less fresh input). B vs cap1 separates by 41 ms, the narrowest reading in the matrix, and it is stated as marginal rather than rounded into confidence. cap2 vs cap4 does not separate at pair grade (1 090 ms against 1 142 ms), so the knee stays a hypothesis. D1 vs D2 still cannot resolve, at rate grade. With that, no cell of the plan is un-run: every question is answered at the grade its decision needs or closed with its reason on the record. The per-run tables, the pair table with its thresholds, and the stored matrix.json are all regenerated from the reports rather than edited by hand, and the old one-rep aggregate was replaced by the two-rep one. The plan also gains its rule 9: a measurement step that changes no code owes no gate - the clean tree and the recorded instrument commit are the guard - while editing the driver or the shared mechanism owes the route's blocking checks.
…identity note is accepted The arm programme closed its measurement phase, and what it had not decided is the rule that lets a run enter the chain path at all. Until now the only thing that entered it was a spec declaring fusion.unitsPerSession - a measurement artifact - plus a capability check inside the arms' driver. So the rule had no home in the product. The new record gives it one, and states the permission rather than the economics. Fusion is decided by the shared runtime; the adapter supplies session capability and decides nothing. Entry needs three conditions - the semantics allow it (sharedSessionLegal stays the only legality rule), a continuable task exists at the boundary, and the host supports session reuse - and a missing one means the run proceeds unfused rather than failing. The board carries claims, acceptance and facts; the decision and its move are a run fact, so they are replayable. Fusion may not relax correctness: each task is still checked on its own for permission, input version, cancellation and acceptance, and the parent still accepts jointly. What is deliberately not declared is economics. The record carries the programme's closing classification instead - observed (coarse fastest here, fine granularity costs, the second slot and fusion each recover part of it), replicated (bound 2 beats bound 1 at rate grade), undetermined (the chain path's own cost, the default bound, any general cost-against-benefit claim), closed (no further samples) - and the alternatives say why: the cap cells' token column did not survive three reps and their price cannot be recovered. The other half is a lifecycle move. "A unit's session comes from the board" was proposed; its mechanism is landed and tested, its field trial ran on the product path, and the ruling above endorses exactly its content, so it moves to implemented with its acceptance criteria answered one by one and its proposal-era headings retired. The arm plan's end state and the fusion planning document now point at the decision instead of restating it.
Asked whether the permission this record announces is wired into the product, the honest answer needed the record to be exact about what is missing. It said "a board-side caller is not wired", which reads as one call site. It is not: the product has no loop that runs a unit at all. The shared module holds legality and selection as pure functions, the coordinator governs runs, the extension offers the worker's tool surface - and nothing decides "admit the next unit or close the session", obtains a runner and runs the unit. Every caller of decideSessionMove and openUnitSession is a test, and every caller of a session runner is the arms' driver or the extension's live path, both reached from evals/. Both language versions now say that, and say the consequence: the rule is enforced where the decision is made but the product cannot reach it yet, so wiring it is a product-side executor and a decision of its own rather than a call site.
…port The loop that runs a plan to completion through the board now lives with the other shared execution decisions (`src/integration/ooo-dispatch.ts`) instead of inside the arms' driver, which becomes its caller. What the driver keeps is its spec format, its worker, its parent check, its report and its session identity - its declarations and its measurement, not its copy of the ordering. The board is a port (`DispatchBoard`): candidates, accepted, claim, put, submit. The experiment's own board satisfies it structurally, and nothing in the shared loop names it, so a product path can provide the same operations without depending on a research instrument. Three defects a review named are fixed in the same change: - the session decision is recorded with the boundary it belongs to (task, attempt, entry). Without them every decision in a run collided on one store key and only the first was written, while the function still returned the move it had computed; - the session identity is the caller's (`SessionCapability.identity`), not a key the shared loop invents from a unit name; - the chain path is entered only when the caller declares that this host can carry several units in one session, and a declared slot count is no longer cut to one chain when it is. Also: agents' tooling and the repo's own anchors. `evals/**` has no tsconfig, so a type error there is invisible to `tsc` and to `build` - `lsp_diagnostics` is the gate that saw it. The mutation sweep gains a per-suite timeout, a `--mutant` filter and a name-filtered fast path: a mutant that livelocks now costs a bound instead of hanging the sweep, and a sweep command can be kept inside a caller's time budget. Decision: docs/decisions/implemented/2026-09-19-dispatch-loop-is-shared.md
A unit the board refused or whose worker failed is reported, not re-asked: the same pass cannot change that answer, and asking again is how a run spins instead of ending. A retry belongs to the caller's next call, which is where a retry policy belongs. The sweep also bounds a run filtered to one case more tightly than a whole suite, so a mutant that livelocks costs a bound.
…s teeth The loop is driven through a board that answers in memory: legal set, claims, entries, verdicts - no store, no git, no check runner. A case costs milliseconds instead of the seconds a store-opening suite costs, which is what makes the sweep below seconds instead of an afternoon, and it is also the product-side proof that the port is a port: the loop cannot tell this board from a real one. Eight teeth, every one of them caught by the case it names: - the board's answer decides what runs, not the declared plan order; - a declared slot count is reached, the claims overlap, and each unit runs once; - a unit's check is outstanding while an independent unit's worker runs; - a refused claim leaves the unit on offer and does not end the pass; - a failed unit is asked once in a pass (the once-only rule); - a fused chain stops at the declared bound; - a worker that starts its own session is not reported as fusion; - a failed worker is not reported as a run that finished. The run-fact case is the one that writes: it asserts that each boundary's session move names the unit, its attempt and the entry it belongs to, so the store records one fact per boundary instead of one per run. The sweep itself is now bounded, because an unbounded one is a sweep nobody can run: a case is filtered by name (`expect` already named it) with the whole suite as a fallback, a run that does not finish is killed and reported as such, `--mutant` selects one tooth, and the result carries where the time went.
…thing
A unit's acceptance ran as `node --test <fixture>.test.ts` inside a throwaway git
worktree built per candidate: mkdtemp, `git worktree add --detach`, a node_modules
junction, and a cleanup. Measured: 580/79 ms add, 254 ms remove, ~0.94 s per unit,
~5.0 s per dispatching case, 82.5 s for the driver suite - all of it paid to give a
test file three files to import.
- `DataCheck` + `verifyDataChecks` in the shared candidate module: a check is a
function over `{files, frozen}` that answers a verdict, so nothing is created and
an interrupted run leaves nothing behind. The verdict rule is shared with the
command kind, so the two cannot disagree by accident.
- `verifyCandidate` takes a workspace its caller prepared, not a repository and a
revision; it writes the candidate's files, runs the checks, and reports. It cleans
nothing, because the working tree is the caller's business.
- `evals/ooo-execution/data-check-runner.ts`: the arms' checks are their own fixture
test files, transpiled and evaluated in process, with the fixture's relative imports
resolved against the candidate's file set. The specs declare `{label, test}`.
- `evals/ooo-execution/candidate.test.ts` retires with the machinery it tested;
`mutation-probe` prepares and resets its own worktree, because a caller that needs a
workspace owns it.
- `tools/mutation-teeth.ts` no longer prints a "NOT caught" problem for a mutant it
counts as caught because the case spun; the spin is shown as the reason instead.
Measured after: the three suites pass 33 cases in 6.8 s (driver 82.5 -> 4.7 s,
families 1.3 s, loop suite 0.6 s unchanged); 13 of 13 mutants are still caught by the
case each one names, both targets restored byte-identically; `test:product` 1519 pass.
Decision: docs/decisions/implemented/2026-09-20-tests-need-no-filesystem.md
`rtm:check` counted an assertion as `proven` when its `strength` was `decision` and its check id resolved - which only proves the check exists. Nothing said whether it ran, whether it passed, or whether the reading was about the revision in front of the reader, so a green run read as assurance over claims no execution had touched. - Inventory stays inventory: contracts, assertions, bound, documented-only, uncovered, orphans. No field of it decides anything, and the report ends by saying so. - Every assertion gets a standing from recorded execution: `agent:verify`'s `.nmg/verification/latest.json` names the commands it ran, their status and the revision. `executed` (passed, at this revision), `historical` (passed elsewhere, never counted as execution), `not-run` (skipped or failed, with the recorded reason - only a current *failure* fails the gate), `not-recorded` (no entry, no command carrying the check, or no evidence file), plus `documented-only` and `uncovered`. - Each risk class (strength/kind/stage) is judged on its own evidence, and a contract whose scope resolves to several routes is listed as a cross-module change with its own routes and counts. Assumptions and open counterexamples are listed. - `proven` is renamed `decision`: the old name claimed over the check's subject what binding an id cannot give. - The CLI prints one line per assertion, per risk class and per cross-module contract, and closes with "no overall verdict". Proof on the real repository: with the evidence left by a dry run, every assertion reads `not-run (skipped (dry run))` and the gate still exits 0; after a real `agent:verify`, seven assertions read `executed (build=passed; test:product=passed)` at that revision and the five whose carrying command the run never executed read `not-recorded`. Tests: tests/tools/rtm-check.test.ts 16 pass (was 11): inventory vs standing, current evidence making an assertion executed, another revision reading historical, a skipped command not failing the gate where a failed one does, and a cross-module contract judged on its own. Decision: docs/decisions/implemented/2026-09-20-rtm-evidence-aggregation.md
…ed loop The dispatch-loop decision was written when no product caller existed for `dispatchPlan`, and two of its claims followed from that absence. The audit replaces the inference with observed facts, verified against `e3508a37` and re-run unchanged at `c01d3fe` without touching runtime code or buying model runs. - The product already has an Agent-driven execution path: the board wakes an existing host session (`deliverWake` in `.pi/extensions/nmg/index.ts`), the Agent claims, works and delivers through the existing `nmg_board` operations, and an independent judge binds the verdict to the artifact digest. The extracted `DispatchBoard` port leaves `BoardAdmission` as the research implementation, so the product does not open its `ooo_probe_*` store - which the decision now says instead of "the product cannot reach it". - The boundary is concrete rather than asserted: `task-run-surface.test.ts`'s `registerAndFreeze` freezes `J.dependencies = ["P"]`, and the case `a managed entry's lifecycle write goes through the run, and the run records it` then adopts and claims J without P being produced or accepted, with `entry-bound` and `board-claim` as the observed facts. That managed claim enforces registration, binding and cancellation; it does not enforce the dependency-acceptance predicate. - `claimTaskBoardEntry` and `resolveTaskBoardEntry` both call `promoteNextSerialPending` (conditionally on the claim in the first case), so promotion is a handoff becoming available after a claim or a closure, not a dependency becoming satisfied. The audit keeps those transitions distinct instead of describing them as missing. - Verification: 41 pass, 0 fail over the loop, fusion-plan, session-fact and task-run surface suites, run the repository's way. A live daemon observed at `2026-09-20T11:54Z` answered `compatible: true` while its advertised `methods` omitted `taskRun`, which the worktree's service implements: an installed-instance gap, not a missing source method. Two corrections to the incoming text: the verification command is written the way this repository runs tests (no wrapper), and the two readings that the data-check change invalidated are replaced rather than left standing - `families.test.ts` is 1.3 s now and was ~30 s while each acceptance built a candidate worktree, and the plan-driver mutation lane's clean run is 5.5 s against ~92 s, with the 2026-09-19 lane numbers marked as that instrument's.
…ape, and say so The fusion trial's two spec files are the recorded inputs of a completed run, and they declare checks the way the driver declared them then. The arms' checks are now the fixture test files themselves, so the stored pair is no longer runnable as it stands. The README now says which form they carry, which decision changed it, and that re-running the plan means regenerating the pair from the current fixture.
…n see it CodeFactor reads the repository eslint config, where `no-console` is a warning except on the surfaces that report through stdout by design. The config's own rule is that every `files:` block must be anchored in a directory `npm run lint` scans (held by tests/tools/eslint-config-coverage.test.ts), so exempting docs/ would have produced exactly the silent intent that rule exists to prevent. The generator moves next to the fixture it reads, takes the harness's name, and the trial README points at it.
… move The previous commit carried the rename alone: the `git add` that should have staged the edits listed the generator's old path, which no longer exists, so git rejected the whole pathspec list. This commit is the rest of that change - the file says where it now lives and why, and the trial README points at its new path in both places it names it.
The record sat in proposed/ while its blueprint has been the current design since 2026-09-01 and the runtime it describes is live code. It moves to implemented/ with what that folder requires: Problem / Decision / Alternatives considered / Consequences, plus a Deferred section. The eight criteria stay under Decision, so the blueprint's acceptance mapping still has the text it cites, and the three items the blueprint itself marks Partial or intentionally deferred are named as deferred instead of being smoothed over. `Approved: unrecorded`, because the acceptance predates the approval field: the debt is the missing act, not an unapproved decision. The decision's compatibility layers are gone rather than kept - no `SessionRuntimeAg` and no continuation map remain in the tree, the query-scoped AG is the projection revision, and the disclosure ledger is what the Pi extension, the Claude plugin, WorkBuddy and DSH write through `markDisclosed`. Five design files pointed at the old path; design.md also called the decision proposed. The blueprint's own `decision §Proposal` and `§Acceptance` references now name the Decision section, which is where the criteria live.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this branch carries
The offline half of the OOO task-unit-semantics design, plus the session continuation surface the
fused arm needs. It is one branch because the pieces share one mechanism: the loop that sequences a
run was extracted into the shared layer first, and everything after it is a caller or a check on
that loop.
The dispatch loop is shared, and the board is a port (
5403fafb,b140ff75,e3508a37)src/integration/ooo-dispatch.ts:DispatchBoardport,DispatchTicket,dispatchPlan/dispatchUnit. The driver is now a caller, not the owner of the loop.tests/integration/ooo-dispatch.test.ts, in-memory board) so the loop'steeth no longer need a whole driver run to fail.
The session continuation surface (
68974670…53fbb10d,41ca6c42,02964663,070a42ac,10b2d15a,d5af9eff,7004f971,ce9b1051)move in one call (
sharedSessionLegal+nextSessionMove,decideSessionMove,openUnitSession).adapter — a test now fails any file that asks the adapter for a name the shared mechanism owns.
task-level cancellation closes that task instead of the whole run.
The arms' checks read data, and the host prepares nothing (
af07f5af)DataCheck+verifyDataChecks({files, frozen, checks})in the shared candidate module: a checkis a function over a file set, judged in process, with the same verdict aggregation as the command
checks.
evals/ooo-execution/data-check-runner.tsruns a fixture's own test file in memory (transpiled,with a per-run module system and
node:test/node:assert/strictshims). The specs declare{label, test}; the test file and its relative imports come from the candidate's file set.candidate.test.tsis retired.verifyCandidatekeeps one caller (mutation-probe.ts) and nowtakes a caller-prepared workspace — it neither creates a worktree nor cleans one.
Measurement (
9803ce7a,6d1dbe2a,40c542b3,6264aaf0,86d67f2f,230d259a+ theplan/ledger records)
replaced were replaced rather than left standing.
~35% less wall time, pair-grade reps, and the arithmetic that says which reading the sample
cannot resolve.
The traceability report is evidence, not a count (
c01d3fe6)rtm:checkreports a per-assertion standing derived from recorded execution at the same revision(
executed/historical/not-run/not-recorded/documented-only/uncovered), riskclasses and cross-module contracts separately, plus assumptions and counterexamples — and says in
its own output that it gives no overall verdict.
Product call path audit (
f99b09b5): the extracted loop is read against the product's owncall path, with the facts it turns on checked in the code rather than recalled.
Follow-ups from review (
5cc822e1,43e9cc1b,0ef177f2): CodeFactor reads the repositoryeslint config, whose own rule is that every
files:block must be anchored in a directorynpm run lintscans — so the trial's spec generator moved out ofdocs/toevals/ooo-execution/make-fusion-trial-specs.mjsrather thandocs/getting an exemption; and thesession Active Graph decision moved from
proposed/toimplemented/, with the three items its ownblueprint still marks Partial or intentionally deferred named as deferred (see below).
Verification at the tip (
0ef177f2)docs:check,glossary:check,check,test:product(1519 passed),build— all passed;rtm:checkreads these asexecutedat thisrevision. The documentation commits after them are verified by the documentation route
(
docs:check,glossary:check).tests/integration/ooo-dispatch.test.ts+evals/ooo-execution/plan-driver.test.ts+evals/ooo-execution/families.test.ts: 33 pass, 0 fail, ~6 s (the driver suite went 82.5 s → 4.7 swhen its checks stopped needing a git worktree per candidate; a dispatching case ~5.0 s → 0.17 s).
src/integration/ooo-dispatch.tsand 5 of 5 onplan-driver.tscaughtby the case each mutant names, both trees restored byte-identically (driver lane clean 106.9 s →
5.5 s).
docs:check276 files / 0 errors,complexity:gateok,agent:context:checkok,ci:uncovered-testsclean.Known limitations, not fixed here
evals/**has no tsconfig and is not reached by CI jobs; the arms' route (npm run check,test:product,build) is its gate.npm run check:testsis red on pre-existing errors outsidethis branch (
evals/natural-maintenance,evals/omnimemeval,evals/topology, parts oftests/cli,tests/core) and is unchanged by it.runnable as they stand; the trial README now says which form they carry and which decision changed
it.
implementedwithApproved: unrecorded(the acceptancepredates the approval field): its runtime, task-frame lifecycle, branch ownership, TTL artifacts
and host-neutral disclosure ledger are in
src/core/session-active-graph.ts, while the fullmultidimensional budget account, automatic MGR admission and HA admission stay deferred.