Repository navigation
fix(gate)!: answer the reply judge's first review: refused calls, a failed last turn, pending batches, quoted participant text and too-long requests - #850
Conversation
…s document keeps its own structure Answers the first review of the reply judge (anima-research#801). A judge call the framework refuses without making it (a provider hold on the agent or on the judge's model, a surgery's hold on the store, a stopping framework) is a `refused` verdict: counted in judgeState apart from errors, outside the three-failures streak, traced with the hold's words and stated end, and named in gate_status while it lasts. - While a refusal's stated wait lasts (at least a second on, since a hold whose end has just passed refuses with no time left until the framework's timer releases it), the judge isn't asked: asking compiles the agent's context only to be refused. Under onError "wake" each batch wakes the agent unjudged; under "skip" it's held in memory and judged once the wait ends, by the gate's own timer. - A refusal with no stated end, under "skip", keeps held batches through turns' ends. A batch that arrives with no turn running is still asked, taking its rule's held batch in with it, so a hold that lifted unannounced ends there without polling and a rule's events stay in order. EventGate.providerCallsReleased(), called where an operator releases a provider wait (always: the wait may have lifted unannounced) and where a surgery lets go of the store, releases them too. A provider hold on the agent is asked about again at its stated end by the refusal's timer, so its release isn't wired. - The gate numbers its asks and keeps the ask of the newest outcome it knows of. A refusal stands only when its call is newer than every outcome since; a verdict ends one only when its call is newer; a failed call changes neither; a release marks every call asked so far as older. The gate can't see admission, so asking order stands in for it. - The refused batch is held before its trace is emitted. A stated wait that doesn't end at a time a Date can name counts as no end: a timer given a non-finite delay fires at once, and gate_status must name the end; a delay past 2^31-1 ms is waited out in steps. When the agent's last turn failed with its retries spent and no turn has completed since, <if_you_wait> opens with one line: the turn failed and may not have answered all of what woke it, then that a wake for it is already queued, is held (due to be let go at its stated end, or when the hold is released), or that nothing runs it again, so what woke it stays unanswered until something wakes the agent. The framework computes it (ReplyJudgeDeps.failedLastTurn) from the same reading of its state as the wake list (wakesOnTheirWay): requests pending, parked, in a scheduler pass, or a start waiting for an auxiliary call count as queued; a cooldown whose release hands on a request or queues a retry, or a wait on the agent's model under which the scheduler holds a queued request, holds one. Neither ending says a turn will start, since the inference policy can decline a wake. With the last ending, the judge's model answered REPLY 10 of 10 on both measured bases, and WAIT 10 of 10 without the line. A batch is judged by its rule as the rule is when the judge is asked; a reload clears the changed rules' state, installs the new rules, then sends their batches (fireCleared). A judge request lists every other pending batch as maybe, with what a held one waits for (UpcomingWake.when, a time in words, for one due at no time anyone can name), asks held batches in turn so each request lists the rest, and the wake list shows eight, soonest first, saying how many more are only maybe. The flush checks the refusal when its microtask runs. Participant text in the judge's document is escaped (&, <, >), with each line after the first indented, so it can't open or close a tag or start where the document's structure does; names, channels and author ids are kept to one line with square brackets escaped, so one can't end the [name] it's in; each wake's words are quoted the same way, since a queued request's reason can carry a participant's name. The line-break rule is src/supplied-text.ts (oneLine, normalizeLineBreaks), for the framework's notices to share. Texts that said more than the code does: the budget is met by an estimate, which text denser than three characters a token, or a newest message or event that alone is over it, can pass; the dry run leaves out injections; Bedrock drops cacheTtl; a membrane whose formatter is OpenAI Responses', outside the adapter's subscription mode, keeps it; the header's order of the document. The fragment, reply-judge.added.md, carries them, since no release has carried the judge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…get on its own An event's content is cut to 200 characters when the gate takes it (MAX_CONTENT_SNIPPET), so the newest event can't be over a budget of at least 4,000 tokens by itself; the clause the previous commit added for it goes. The newest message is always shown, cut to 12,000 characters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the ratio Answers R1-4's case where the judge's request exceeds the model's window: a budget is met by an estimate, which text far denser than the estimate's ratio, or a budget set above the window, can pass, and calibrate runs only on a response, so nothing would adapt and every batch would fail the same way. A context_length failure now halves the characters-a-token ratio, clamped at 1, rebuilds the request and asks the same batch once more; a second failure goes to onError as any failure does. At a ratio of 1 already it isn't asked again, since the request would be the same. The halved ratio stays, and later responses correct it as before. Also: the timeout test waits for the timed-out judge's wake instead of sleeping a fixed 200 ms, which a loaded full suite overran. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… at, from a fresh window Answers the review of the retry. The retry halves the ratio the refused request was built at, which buildRequest now returns, and never raises the estimate (the lower of the two), so two requests refused together halve it once; at a ratio of one already the refusal isn't asked again. It drops the agent's window anchor before rebuilding, so the retry starts a fresh window at the halved budget rather than keeping an anchored window that still fits it. The texts say the lower ratio stays for later requests until responses correct it, and providerCallsReleased's doc says a refusal reported after a release doesn't stand. Tests: a retry from an anchored window that still fits is smaller; three refused batches ask 2, 2 and 1 times and leave the ratio at 1; two requests refused together leave it at 1.5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…essed marker leads the event line Two more parts of R1-3's answer, and the operators' texts from a seat read. A verdict decides whether the agent wakes, not whether its events reach its context. MCPL's channel and push events are stored when they arrive; any other event a judge rule matched (an agent's own rule can match any type) had its content quoted only when the batch woke the agent, so a wait, or a failure under onError "skip", left it out of context for good. Now such a batch stores those events' content as a delivered batch quotes them, with no wake (source "gate:judge-wait"), through one helper shared with deliverEvents. A defer rule is unchanged: it still stores nothing for such an event. The judge's event line puts "(addresses the agent)" right after the time, ahead of the channel and author, which participants can name. gate_status: under "skip" with a stated end, held batches are judged after it "or sooner if the hold is released"; with no end, it says nothing asks the judge again on its own, and they're judged once a new batch gets a verdict or the hold is released. The judge's listing of another rule's batch held with no end says it goes right after this call if this call gets a verdict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Takes back the store the previous commit added on the judge's no-wake branch. The framework's own modules whose events reach the gate (api, workspace, discord) store their message for the event in the same response that asks for inference, before the gate is asked, so a wait already left the content in context and the store only wrote a second copy on every wait. deliverEvents is as it was before that commit. JudgeConfig's doc and the changelog now say the events are in the agent's context either way when they're stored as they arrive (MCPL's channel and push events, and the framework's own modules' events), and that a wait, like defer, adds nothing more. The addressed marker's move and the seat read's wordings stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
event-gate-judge waited out the gate's own timers in real time: the debounce, the refusal's one-second floor and stated ends, and the windows a test watches for nothing to happen. At 8e54e6d the file took about 30 s (21 s at main), and a negative window waited out in real time can miss a regression that comes late under load. Every test now runs on node:test's mock timers (setTimeout, setInterval and Date), enabled in the file's beforeEach. setImmediate stays real, so `settle()` lets promise chains, the gate's queueMicrotask and I/O callbacks run between steps. `advance(ms)` steps the clock 1 ms at a time, settling after each step; it replaces every fixed sleep. `until()` is a condition on the same clock, its limit in mocked time. A negative window now steps past its deadline and asserts nothing happened, which load can't change either way. `idle(framework)` waits for runUntilIdle while the clock steps, with a 60 s mocked bound no healthy run reaches. It replaces both Promise.race([runUntilIdle(), sleep(3000)]) caps, which went on after 3 s whether or not the framework was idle, and each direct await of runUntilIdle. The framework suite's own 20 ms polling `until` is gone in favour of the file's. Each framework test's teardown puts the real clock back before stop(). The elapsed-time assertions now hold to the stated end: >= 1000 for a one-second end or floor, >= 1200 for a 1.2 s end, where they allowed 50 ms of real-timer slack. One test stays on real time: "a mention rule ahead of a judge rule, end to end over an MCPL server" spawns the MCPL fixture as a child process, whose command polling and replies run on its own real clock; stepping the parent's clock while it waits on that process would race it. `useRealClock()` puts it back on real timers, and `advance`/`until` poll in real time there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…two comments and the doc as the code bears them out From a review of the previous commits: - The wait store's revert (ff81a57) removed the test that pinned the store and added none for the rule the doc now states, that a wait, like defer, adds nothing more. The removed test is turned round: a wait on a workspace:modified event writes no message and wakes nothing. - JudgeConfig's doc and the changelog said the framework's own modules store "theirs"; an API inference request, and a workspace event with no paths, carry no message, so they now say the modules store the message an event of theirs carries. - settle()'s comment said I/O callbacks run between the mocked clock's steps; only those already due do, and slower I/O isn't waited for. - The MCPL test's comment gave the child's own clock as the reason it runs on real time; the reason is that it waits for the child's replies, which arrive in real time, and stepping a mocked clock while waiting would spend the wait's bound first. (The previous commit's message gave the first reason; this is the one that holds.) Likewise until()'s and idle()'s bounds are counted in mocked milliseconds, one per step, not in elapsed time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wake to its exact time A 5 ms step is taken because it cuts the CPU the file spends and no assertion loosens past one step. Every elapsed-time assertion in the file is a lower bound (>= 1000 or >= 1200), so a coarser step can only make an ask land later within a step. advance() ticks min(5 ms, what remains) and settles after each tick; until() steps 5 ms at a time. CPU time (children's user + sys), five interleaved runs each, no added load: real timers 2.1-2.4 s CPU, 28-33 s wall; 1 ms steps 2.3-2.8 s CPU, 3.7-9.6 s wall; 5 ms steps 1.8-2.1 s CPU, 3.4-6.4 s wall. anima-research#801's self-wake check allowed 2 s of real-timer slack (Math.abs(at - (t0 + 600000)) < 2000); on the mocked clock the wake's time is exact, so it's an equality. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0.28.0 shipped anima-research#801's reply judge and folded its fragment into CHANGELOG.md, so this change's entries now say what changes against that release, in four fragments by kind: - breaking: judgeState.last.verdict and the gate:judge-verdict trace's verdict gain 'refused'; code handling each value needs a case for it. - added: EventGate.providerCallsReleased(), ReplyJudgeDeps.failedLastTurn and FailedLastTurn, UpcomingWake.when, and buildRequest()'s ratio. - changed (behaviour 0.28.0 set on purpose, or said nothing about): refused calls counted, traced, named in gate_status, held under skip and woken unjudged under wake while a stated wait lasts (R1-1); the failed-turn line (R1-2); quoting and the addressed marker's place (R1-3); the too-long retry (R1-4). - fixed (code brought to what 0.28.0's notes say, each line quoted): the rule in force when asked, "by the rule as it is when the judge is asked" (R1-5); every pending batch listed, "other rules' pending batches" (R1-6); and the sentences of 0.28.0's notes on the judge that weren't true, each quoted beside what is: the dry run, the native formatter, the token budget, cacheTtl on Bedrock, and what a wait leaves in context. A scratch fold with the release script (version 0.29.0) puts them under Breaking, Added, Changed and Fixed with no complaint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: merge and fast-follow. 3 medium severity findings can be fixed after the merge.
Reviewed commit b7ca786 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.
Does this change belong in the project?
It fits the project. This change fits. It is a follow-up that fixes the reply judge shipped in 0.28.0, answering that feature's first review item by item, and it stays inside the existing gate/judge modules with the changelog fragments the project requires.
5 reasons with sources
- It fixes and corrects a feature the project already ships rather than adding new scope. Each item answers a finding (R1-1 to R1-10) from the review of #801, the reply judge released in 0.28.0. The items cover refused calls, a failed last turn, pending batches, quoting participant text and too-long requests. Some fragments also correct wording in 0.28.0's notes. (changelog.d/reply-judge-review.fixed.md, changelog.d/reply-judge-review.changed.md)
- The code goes where the judge and the gate already live. The gate holds and releases batches and the judge builds its document. The framework supplies the new failedLastTurn hook, the same way it already supplies upcomingWakes. The one new file, src/supplied-text.ts, holds small text helpers. Only reply-judge.ts uses them, which a separate review notes as a minor issue. (src/gate/ (event-gate.ts, reply-judge.ts, types.ts), src/framework.ts)
- The breaking change is declared the way the contributing guide asks. The new 'refused' value of the verdict gets a .breaking.md fragment, the title is marked
!, and added, changed and fixed fragments land in the same PR. (CONTRIBUTING.md, Changelog) - It is one coherent change despite its size: every part answers one review of one feature. The guide expects review findings to be answered one by one. #801 had already merged, so a single follow-up PR that names each finding and lists what it leaves out ("Out of scope") matches that practice. (CONTRIBUTING.md, Review process)
- Tests come with the behavior changes, and the PR says they fail on 0.28.0's code. test/event-gate-judge.test.ts and test/reply-judge.test.ts are extended. The description says the tests fail on main (59d6095) for each of the first six findings. (CONTRIBUTING.md, What a PR should contain)
Findings
3 findings of medium or high severity, see the inline comments:
- Medium severity. Public EventGate.upcomingWakes changed its second parameter from a policy name to a private JudgingState.
- Medium severity. A refusal that doesn't stand is still logged as 'is held' and traced with an
untilthe gate never uses. - Medium severity, test coverage. Every test in the file now runs on a mocked clock that races ahead of real I/O.
5 more findings, less severe
- Low severity, documentation,
src/gate/types.ts:261-264. TheonErrordoc doesn't describe the new refusal behavior.ReplyJudgeOptions.onErrorstill says only that"skip"doesn't wake the agent when the judge fails, times out or answers unreadably. The change givesonErrornew meanings for refused calls. Under"skip", refused batches are now held in memory until the hold's stated end or a release, and a refusal with no end can hold them indefinitely. Under"wake", while a stated refusal lasts, every judge rule's batch wakes the agent without the judge being asked. A host choosing between the two settings learns none of this from the option's own doc. TheJudgeConfigdoc comment, which says a failure under skip means the next batch is judged afresh, has the same gap. - Low severity, documentation,
src/supplied-text.ts:1-6. The module doc says these helpers serve framework notices, but only the reply judge uses them. The header of the new src/supplied-text.ts says it is for text placed beside the framework's own structure: "the reply judge's document, and notices the framework writes". The only importer is src/gate/reply-judge.ts (plus a test). No framework notice usesoneLineornormalizeLineBreaks. A reader could wrongly assume framework notices are already protected against line-break injection. - Low severity, maintainability,
src/supplied-text.ts:8-10. Line-break set duplicated from workspace/result-text.ts.LINE_BREAK(\r\n|[\r\n\u000b\u000c\u0085\u2028\u2029]) repeats the set already defined in src/modules/workspace/result-text.ts:13 ([\n\v\f\r\u0085\u2028\u2029]). The new module is meant to be the shared place for handling supplied text, so the workspace module should use it, or the two sets will drift apart. - Low severity, test coverage,
src/framework.ts:5527-5535. wakesOnTheirWay's pass-request and pending-start branches have no tests.wakesOnTheirWaynow counts requests taken by a running pass (passRequests) as queued. When no request is queued, it also reports 'a start waiting for an auxiliary call to finish' whileproviderGates.primaryDepth > 0and no turn token is held. Both feed the judge's failed-turn line and the wake list. The new failed-turn test only exercisespendingRequests,parkedContinuations, the acceleration cooldown and provider waits. A mistake in either untested branch would make the judge say "Nothing runs it again" while a wake is in fact on its way. - Low severity, documentation,
src/gate/types.ts:308-313. Custom judges now get wakes withwhenand noat, which 0.28.0 read as 'as soon as free'. Before this change, anUpcomingWakewithoutatmeant 'due as soon as the agent is free'. The gate and the framework now also send entries that havewhenand noat, such as 'when the hold on it is released', and gate entries with neither field (batches being decided or held, events buffered behind a turn). AJudgeFnthat the host passes toEventGate(thejudgeoption) and that renders wakes the way 0.28.0'sifYouWaitdid will present a hold that only a release ends as 'as soon as it is free'. Theatdoc onUpcomingWakestill says absent means 'due as soon as the agent is free', which now contradicts the newwhenfield, and the breaking fragment doesn't mention it.
For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.
| * the gate delivers when that turn ends. | ||
| */ | ||
| upcomingWakes(agentNames: string[], exceptPolicy?: string): UpcomingWake[] { | ||
| upcomingWakes(agentNames: string[], asking?: JudgingState): UpcomingWake[] { |
There was a problem hiding this comment.
Medium severity. Public EventGate.upcomingWakes changed its second parameter from a policy name to a private JudgingState.
upcomingWakes(agentNames, exceptPolicy?: string) was a public method of the exported EventGate class (re-exported from src/index.ts). It is now upcomingWakes(agentNames, asking?: JudgingState), and JudgingState is an internal interface that is not exported. Any host that called it with a policy name to leave that rule out no longer compiles. It also can no longer express "every pending batch except rule X's", because only the gate can build a JudgingState. The breaking fragment (changelog.d/reply-judge-review.breaking.md) names only the new 'refused' verdict, not this signature change. Either keep the string overload or record the break in the breaking fragment.
| if (state.ask > this.judgeNewestOutcome) { | ||
| this.judgeNewestOutcome = state.ask; | ||
| this.judgeRefusal = { hold, ...(until !== undefined ? { until: Math.max(until, now + JUDGE_REFUSAL_MIN_RETRY_MS) } : {}) }; | ||
| } | ||
| const wakes = this.judgeOnError === 'wake'; | ||
| if (!this.disposed && !wakes) { | ||
| this.holdForJudge(policyName, [...state.events, ...state.queued], state.judge); | ||
| if (this.judgeRefusal) this.armJudgeRefusalTimer(); | ||
| else this.flushJudgeHeldIfClear(); | ||
| } | ||
| const n = state.events.length; | ||
| console.error( | ||
| `[gate] Policy "${policyName}": the reply judge's call was refused (${hold}), so its batch of ${n} ` | ||
| + `${n === 1 ? 'event' : 'events'} ${wakes ? 'wakes the agent' : 'is held'} (judge onError "${this.judgeOnError}")`, | ||
| ); | ||
| const record = this.judgeRecords.get(policyName) ?? { verdicts: { reply: 0, wait: 0, error: 0, refused: 0 } }; | ||
| record.verdicts.refused++; | ||
| record.last = { at: Date.now(), verdict: 'refused', reason: hold, eventCount: n, durationMs }; | ||
| if (this.config.policies.some((p) => p.name === policyName)) this.judgeRecords.set(policyName, record); | ||
| this.emitTrace({ | ||
| type: 'gate:judge-verdict', | ||
| policyName, | ||
| verdict: 'refused', | ||
| woke: wakes, | ||
| eventCount: n, | ||
| durationMs, | ||
| hold, | ||
| ...(until !== undefined ? { until } : {}), | ||
| timestamp: Date.now(), | ||
| }); |
There was a problem hiding this comment.
Medium severity. A refusal that doesn't stand is still logged as 'is held' and traced with an until the gate never uses.
In refusedJudging, until is computed from the error's retryAfterMs and always goes into the gate:judge-verdict trace. The log line always says the batch "is held" under skip. That happens even when state.ask <= judgeNewestOutcome, meaning the refusal doesn't stand. In that case nothing is held to that end: the batch is asked again at once through flushJudgeHeldIfClear, or held for a different, standing refusal. Even when the refusal stands, the trace carries the raw until, while gate_status and the timer use Math.max(until, now + JUDGE_REFUSAL_MIN_RETRY_MS), so a zero-length refusal is traced as ending now but is retried a second later. Anyone reading traces or logs to see when a held batch will be judged gets the wrong answer in these cases.
| assert.deepStrictEqual(judge.requests[1]!.events.map((e) => e.content), ['lol']); | ||
| judge.answer({ reply: true, reason: 'stopped, so ignored' }); | ||
| judge.answer({ reply: false, reason: 'the turn answered it' }); |
There was a problem hiding this comment.
Medium severity, test coverage. Every test in the file now runs on a mocked clock that races ahead of real I/O.
beforeEach turns on mock.timers (setTimeout, setInterval and Date) for every test in the file, including the framework-level tests that read a real store and compile context. until and idle then move the mocked clock forward 5 ms after a few setImmediate turns. The file's own comment says "slower I/O isn't waited for", and the MCPL end-to-end test has to switch back to the real clock for this reason. In the other framework tests (frameworkWith, the failed-turn test, the provider-hold tests), mocked time can pass the judge's 20 s timeout, a hold's end or the 5 s until bound before slow real I/O finishes. These tests can therefore fail intermittently on a loaded CI machine.
…sed batch goes
EventGate.upcomingWakes(agentNames, exceptPolicy?) is public, and 0.28.0
exported it. The judge's request needs every pending batch but the one it's
asked about, its own rule's next batch included, so earlier commits here
changed the second parameter to the internal JudgingState: a host calling
it with a rule's name no longer compiled, and nothing declared the break.
The public method takes a rule's name again and leaves out that rule's
batches, collected, decided or held, as its doc says ("the pending batches
of rules other than exceptPolicy"). The judge's request reads a private
upcomingWakesBut, which leaves out the asked batch alone.
A refused call's log line said its batch "is held" under skip even when the
refusal doesn't stand (an older call's, reported after a newer outcome) and
the batch goes back to the judge at once. It now says which, by the test
the held batches' flush uses (judgeHeldForRefusal). The verdict trace's doc
said the same, and now says a refused batch is held while the judge's calls
are refused. The trace's until stays the hold's stated end, as its doc says.
Docs that hadn't caught up with refusals: ReplyJudgeOptions.onError,
JudgeConfig and the gate's judgeOnError option now say what each setting
does with a refused call. UpcomingWake.at said an absent at means due as
soon as the agent is free; a wake can now come with when instead, or with
neither for a batch the judge is deciding or will decide after a turn, so
the doc lists those, and a changed entry tells hosts whose own judge shows
wakes as 0.28.0's did. JudgeRequest.upcomingWakes's doc names what the gate
lists now. supplied-text.ts no longer names notices it isn't used for.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… word the judge's notes as 0.28.0's code bears them out From a review of the previous commit: - upcomingWakes(agentNames, exceptPolicy) left out the rule's collecting, decided and held batches, but still counted its events buffered behind a running turn. Buffered events carry the rule they came from, so they're left out by it too; the judge's own request still counts them all. - A test now pins the judge's request listing its own rule's next batch (collecting while a held one is asked), which the public method given that rule leaves out. The public test's title no longer claims 0.28.0 listed decided or held batches; it never did. - The fixed entry for the wake list said every pending batch was listed as "maybe"; a debounce's stays certain. It now names what's listed besides 0.28.0's list, and says EventGate.upcomingWakes lists it too. - The changed entry for the too-long retry said 0.28.0 built every later request at the same ratio; 0.28.0 corrected the ratio from each response, so the entry says the refusal left the ratio as it was. - onError's doc and the changed entry for held batches now say a rule keeps its newest 100 events, as it does while a turn holds them. - UpcomingWake.at's doc and the changed entry name the host's quiesce beside a turn's end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ce as what they wait for From a review of the previous commit: - Events in the inference buffer have already been decided to wake the agent: a debounce's batch that fired during a turn, or a judge batch delivered on reply or under onError "wake". flushInferenceBufferIfClear hands them to deliverEvents as a fired debounce's batch is handed, and that batch is listed as certain, so the buffered entry is certain too. "Maybe" stays for judge rules' batches still waiting on the judge. - The buffer also fills while the host is quiesced with no turn running, and is delivered when the quiesce ends. The listing said "buffered behind a running turn, delivered when it ends"; it now names the quiesce, and so do upcomingWakes' doc, UpcomingWake.at's and JudgeRequest's docs and both fragments. - A test pins that the judge's request counts its own rule's buffered events, which the public method given that rule leaves out. - The fixed entry says upcomingWakes leaves out a given rule's buffered events as well as its batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…held-batch cap and a hold past the floor
From a review of the previous commit:
- Buffered events are delivered only once no turn runs and the host isn't
quiesced, both, not when one turn or the quiesce ends. Their entry now
carries that as when ("once no turn runs and the host isn't quiesced"),
so the framework's judge shows the condition and lists it last, instead
of "as soon as it is free" ahead of every timed wake; it reads "N events
buffered, delivered then". The docs and both fragments say the same.
- upcomingWakes' doc said a debounce's batch wakes the agent when it
fires; one that fires during a turn or a quiesce is buffered, so it
wakes the agent when it's delivered.
- JudgeRequest.upcomingWakes' doc said the judge adds the framework's
wakes; the framework's own judge does, and a host's judge function gets
the gate's list alone.
- The fixed entry said the list gives what a held batch waits for; it
gives that for one held for a refusal.
- Tests: events buffered while the host is quiesced are listed, certain,
with when; a batch asked under a refusal with no end, taking in its
rule's held batch, keeps the newest 100 events of the two; the
host-hold test holds for 3 s, past the gate's one-second floor, so a
call made before the hold ends would fail it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eld and buffered wakes so they stand alone From a review of the previous commit: - A batch held for the judge while a turn changing what it reads runs, or the host is quiesced, had neither at nor when, so the framework's judge showed it as "as soon as it is free". It now carries when, "once no turn or quiesce holds it", as the buffered events' entry does. - The no-end refusal's held batch said "right after this call, if it gets a verdict; otherwise once a later call does", which reads wrongly from a host's own call to upcomingWakes, where there is no call. It now says "right after the judge's next verdict, or once the hold on its calls is released", true in a judge request and outside one. - The buffered entry's what read "N events buffered, delivered then", leaning on its when; it now reads "N events buffered for delivery". - UpcomingWake.at's doc and the changed entry put the held batch among the wakes when describes. - The cap test asserts the whole list the judge is asked about, in order, not only its length and ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b7ca786 to
92b0d3c
Compare
…ed-calls Conflict in src/framework.ts: the reply judge's wakesOnTheirWay and failedLastTurnFor, and the heartbeat's heartbeatWakesFor and heartbeatWakeBefore, are each added after upcomingWakesFor. Both groups are kept.
…ed-calls Three conflicts, resolved: - src/framework.ts, upcomingWakesFor: main lists a re-armed turn's provider hold as 'a turn whose retries ran out, sent again (n of m)'; this branch lists the agent's held wake through wakesOnTheirWay. The held wake keeps this branch's wording, and takes main's re-arm wording when the hold is a re-arm's. wakesOnTheirWay now reports the re-arm, and failedLastTurnFor passes on only the hold's end. - src/supplied-text.ts: main's version, which adds the notice marking to the same normalizeLineBreaks and oneLine this branch uses. - test/event-gate-judge.test.ts: both new tests are kept.
…aves to the judge For an agent with the judge wired, wake_remove_rule's description told it in its last sentence that a judge rule's batch, waiting out its quiet period, held or being judged, still goes to the judge by the rule as it was. main's settings toolset shows a tool by its first sentence alone (firstSentence), so that sentence was never shown there. Its substance now opens the description, within firstSentence's 140 characters, and a last sentence says what pending means. Every other agent's description is unchanged, built from the same text. The test checks firstSentence of the description, and the fragment says what changes against 0.28.0.
…-re-arm case says so main re-arms a turn whose retries ran out, by default. The judge test of the failed-turn line had a turn that nothing sends again as its premise, which the default now breaks: the turn is re-armed, held, and the line says a wake for the agent is held. That test now turns re-arming off (exhaustedTurnRearmMs: false) for its case. The re-arm file's judge test also checks that the judge is told the failed turn is held, not that nothing runs it again; it fails when the line ignores the hold.
No textual conflicts. One change beyond the merge, so the result builds:
- src/framework.ts, wakesOnTheirWay: main replaced the provider hold's
emptiedByDrop with emptiedBy ('drop' | 'withdrawal'), and a hold emptied
either way starts no turn when it ends. The wake list now reads
emptiedBy, as main's end-of-hold rule does, so a hold whose held wakes
were all withdrawn lists no wake, as one emptied by a drop already
didn't. Without this line tsc fails here (TS2551).
… no wake Since main's withdrawal can empty a provider hold (emptiedBy 'withdrawal'), and such a hold's end starts no turn, the wake list must not list a wake on its way for it. The test holds one wake under a coalescing subject, checks it's listed, withdraws the subject's wakes, checks the hold stays, emptied by the withdrawal, and expects an empty wake list. It fails when wakesOnTheirWay reads emptiedByDrop instead.
…ery would drop main's deliverEvents now drops, at delivery, an event whose channel (its route's, else its own) is held then. The wake list the judge is shown counted every buffered event as a certain wake, held ones included, so it could tell the judge a wake was certain when delivery would drop all of it. heldNow, the one predicate deliverEvents uses, now also decides which buffered events the list counts; what delivery does is unchanged. The test buffers two events while the host is quiesced, then tunes one channel out: the list goes from two buffered events to one. It fails if the list counts held events.
… its other-model check wakesOnTheirWay tells the judge what will wake the agent if it waits, and a source it reports wrongly leans the judge toward WAIT on a wake that never comes. Three of its sources had no test that failed without them. - A wake a scheduler pass still holds while it awaits an earlier agent's start (passRequests): pinned by a test with two agents, the first one's start held in a module's gatherContext, which lists the second agent's wake as queued. It fails when the list ignores passRequests. - A start that waits behind an auxiliary call, with nothing queued: pinned by a test that holds withAuxiliaryAdmission open and runs a real scheduler pass, whose start parks behind it. It fails without that source. - A provider hold on another model than the agent's not binding its queued requests: no path makes it so. Every hold's waitModel comes from a wait recorded for agent.model, an Agent's model is set only when it's made, and a hold ends with its agent (releaseAgentRuntime deletes it), so an agent made again under the same name starts without one. The list now treats every hold as binding.
…nd refusal's when as it is The judge's wake list and its failed-turn line read one classification (wakesOnTheirWay), which has to follow what the scheduler does with each hold. - A wake queued while a re-armed turn waits for its delay starts the turn at once: the scheduler releases a re-arm cooldown when it takes up another wake and merges the re-armed turn into it. The classification reported such a wake as held until the re-arm's end. With a re-arm and a wake queued, the wake is now listed as queued and the failed-turn line says so. A test queues a wake under a real re-arm and checks the list, the failed-turn ending and that the turn then starts at once. - A wake queued behind a hold that holds nothing yet (one an auxiliary call armed, or one emptied by a drop or a withdrawal) is held by the next scheduler pass until the hold ends, so it is listed as held, as it already was; a test now pins it, arming such a hold through a failed auxiliary call and checking the list and that a pass then holds the wake. - A batch held for the judge under a refusal that stated no end is asked once a later judge call gets a REPLY or WAIT, or the hold on the judge's calls is released, and no turn or quiesce holds it: a verdict on a call asked before the refusal doesn't end it. Its `when` now says that. - The fragment's line on buffered events says those whose channel is held at delivery don't wake the agent, and that one whose channel is held when the judge is asked isn't counted. - Comments: the settings toolset shows a tool by its first sentence alone (firstSentence), which is why a judged agent's wake_remove_rule says in its first sentence what a removal leaves to the judge; that reason is here now rather than in a comment. Two other comments that explained internals are gone, and the held-buffer test's title says what it checks.
One conflict, in test/event-gate-judge.test.ts. main's change there (cb34359) has the judge-timeout test wait for the judge's request to be aborted and the agent woken, within a minute, in place of a fixed 200 ms sleep, with a `sleep` helper and a 60 s bound for it. This branch's version of that test already waits for the wake on the controlled clock (`until`, which steps the mocked timers) and then asserts the abort, so the resolution keeps this branch's side of both hunks; `sleep` and the 60 s bound have no use left. The file is this branch's, byte for byte.
The next release's notes follow 0.30.0, so each fragment line that says
what the framework did before now says what 0.30.0 does. The judge's
source is 0.28.0's at 0.30.0 (reply-judge.ts is unchanged between the
tags, and event-gate.ts's judge code changes only how the last failure is
cut), so most lines change only the version. One differs: since 0.29.0
the judge's list of upcoming wakes names a held re-arm ("a turn whose
retries ran out, sent again (n of m)"), so the failed-turn bullet's old
side says that. The lead lines keep 0.28.0 where they name where the
judge and its notes came from, and .fixed's lead says the notes still
didn't hold in 0.30.0.
This answers @Anarchid's first review of the reply judge (#801, released in agent-framework 0.28.0): under
onError: "skip", when the framework refuses a batch's judge call, the gate now holds the batch until it can be judged; the judge is told when the agent's last turn failed and about every pending batch; and participant text can no longer make the structure of the judge's document. It also asks a request the provider refuses as too long once more, at half the ratio it was built at, judges a batch by its rule as the rule is when the judge is asked, and corrects the texts in the code that said more than the code does. A changelog fragment corrects 0.28.0's notes on the judge where they did too. One change is breaking for code that handles each value of the judge's verdict:'refused'joinsjudgeState.last.verdictand thegate:judge-verdicttrace'sverdict. Each item below that answers a finding names it, asR1-nfrom the review. "Out of scope" names what this leaves of the review.Problem
The review, at
b296996, found these in #801, which has since merged and was released in 0.28.0. This change's tests fail on 0.28.0's code (59d60958) for each of the first six (Tests, below).onError: "skip". Under either setting, nothing traced or counted the refusal.[inference-failed]marker the failed turn left in the agent's context. On two such requests built by feat(gate): judge wake rules — ask a small model before waking #801's code, the judge's model answered WAIT 10 times of 10 each (Not verified says what was measured).<new_events>and open a tag of their own.calibrate, called atsrc/gate/reply-judge.ts:488in 0.28.0).<if_you_wait>didn't list other judge rules' batches being decided or held, or events buffered behind a running turn.maxInputTokensdoc called it the most input tokens one request may carry;cacheTtldoc didn't say that Bedrock drops it.Changes
A refused call (R1-1). When the framework refuses a judge call without making it, the gate records a verdict of
refused:judgeState.verdicts.refused(refused calls, not batches), apart from errors, and outside the three-failures streak;gate:judge-verdictwithverdict: "refused", the hold's words (hold) and its stated end (until), when it stated one;While a refusal with a stated end lasts (R1-1), the judge isn't asked, since asking compiles the agent's context only to be refused. The end counts as at least a second on, since a hold whose end has just passed refuses with no time left until the framework's timer releases it.
onError: "wake", the refused batch, each new batch, and batches held for a turn (at the turn's end) wake the agent unjudged."skip", the refused batch, with the events queued behind it, and each new batch are held in memory, and judged once that end comes.A refusal with no stated end (R1-1), under
"skip", keeps its held batches through turns' ends, with any batch that arrived during a turn.EventGate.providerCallsReleased()).The newest outcome decides (R1-1). Calls are numbered as the gate asks them.
"skip"its batch is held for whatever refusal does stand, or asked again at once when none does, and under"wake"it wakes the agent, as any refused call's batch does.A failed last turn (R1-2). When the agent's last turn failed with its retries spent and no turn has completed since,
<if_you_wait>opens with one line. It says the turn failed and may not have answered all of what woke it, and then one of three things:The framework's wake list and this line read one helper,
wakesOnTheirWay, so they agree. "Queued" counts requests waiting for the scheduler, in a pass's hands, parked as continuations, or a start that holds provider admission and waits for an auxiliary call to finish. "Held" covers a provider hold whose release hands on a request or queues a retry, and a wait on the agent's model under which the scheduler will hold a queued request. A wake queued while the agent waits for a re-arm counts as queued, since the scheduler takes it up at once and starts the turn (test/exhausted-turn-rearm.test.ts:639). Neither says a turn will start, since the inference policy can decline a wake. The framework gives the judge this throughReplyJudgeDeps.failedLastTurn(typeFailedLastTurn).Participant text in the document (R1-3). Message text and event content are escaped (
&,<,>), with each line after the first indented. Names, channels and author ids are kept to one line, with square brackets escaped. An event's "(addresses the agent)" marker comes right after its time, ahead of the channel and author. The line-break rule is its own module,src/supplied-text.ts(oneLine,normalizeLineBreaks).The rule in force (R1-5). A batch is judged by its rule as the rule is when the judge is asked. A reload of gate.json clears the changed rules' state, puts the new rules in force, then sends their pending batches, as a write already did;
judgeBatchreads the rule then.Every pending batch (R1-6). A judge request lists every pending batch but the one it asks about. Besides what 0.30.0 listed, that's other judge rules' batches being decided (with what queued behind them) and every held batch, with what it waits for, as "maybe". It's also events buffered while a turn runs or the host is quiesced, listed as certain, to be delivered "once no turn runs and the host isn't quiesced". A buffered event whose channel is held when the list is made isn't counted, since delivery drops an event whose channel is held then, as it has since feat(dendrite): one registry, lifecycle and fork primitive for agents; admission, reader forks, the all-except scope and speech.locus (#232, #77, #270) #546 brought that check to
main.EventGate.upcomingWakes(agentNames, exceptPolicy?)keeps 0.28.0's signature and lists the same, leaving out the named rule's batches and its buffered events. The judge's request leaves out only the batch it asks about, so the same rule's other batches and buffered events are listed..changedfragment tells a host that gives anEventGatea judge function of its own that anUpcomingWakewithoutatis no longer always due as soon as the agent is free.UpcomingWake.when) and comes last. The framework lists a turn held until an operator releases its wait that way.A request refused as too long (R1-4). A request the provider refuses as
context_lengthis asked once more, from a fresh window, with the ratio it was built at halved, to no less than one character a token, unless it was built at one already. Refused again, it fails as any failed call does. The lower ratio stays for later requests until responses correct it.Texts (R1-4, R1-7, R1-10).
maxInputTokensdoc now say that the budget is met by an estimate, which a request on text denser than three characters a token can pass until a response has corrected the estimate, as can one whose newest message alone is over it, since that message is always shown.gatherContextand MCPL servers'beforeInferencewould inject, andReplyJudgeDeps.previewsays it's without its injections.cacheTtl's doc says Bedrock drops it.onError,JudgeConfig,judgeOnError,UpcomingWakeand thegate:judge-verdicttrace say what a refused call does.wake_remove_rule's description now says in its first sentence that a judge rule's pending batches still go to the judge, by the rule as it was. 0.28.0 and 0.30.0 say it in the last sentence. feat(toolsets)!: the host's own toolsets: a built-in "settings" toolset and MCPL server tabs #860, merged intomainafter 0.28.0, added asettingstoolset, andsrc/host-presentation.ts:318lists that toolset's tools withtoolLine, which shows thefirstSentenceof each description (src/mcpl/tool-signature.ts:163-164), so the last sentence wasn't in that list.JudgeConfig's doc now says that a batch's events are in the agent's context whether the agent is woken or not, when they're stored as they arrive, as MCPL's channel and push events are and as the framework's own modules store the message an event of theirs carries, and that a wait, likedefer, adds nothing more.The changelog. 0.28.0's release folded feat(gate): judge wake rules — ask a small model before waking #801's fragment into
CHANGELOG.md, which stays as released. This change adds four fragments that say what changes against 0.30.0, the latest release:.breaking: the'refused'value, for code that handles each value of the verdict;.added:EventGate.providerCallsReleased(),ReplyJudgeDeps.failedLastTurnwithFailedLastTurn,UpcomingWake.when, andbuildRequest()'sratio;.changed: R1-1 to R1-4, what anUpcomingWakewithoutatnow means, andwake_remove_rule's first sentence;.fixed: R1-5 and R1-6, where the code didn't do what 0.28.0's notes say and still didn't in 0.30.0, and five sentences of 0.28.0's notes that said more than the code does, each quoted with what's true. Those are the dry run, the native formatter,maxInputTokens,cacheTtl, and the events staying in context on a wait. For the formatter, a membrane whose own formatter is OpenAI Responses', outside the adapter's subscription mode, keeps it whatever a request asks.What each part is for
refusedverdict, its count, trace and notice. It makes a refusal visible apart from a judge that's failing. Counting it as an error would set off "the judge's model may not serve it" for a provider hold, and leaving it unrecorded is the defect. Its cost is one more value in two verdict unions that 0.28.0 released, so code that handles each of their values needs a'refused'case, as the.breakingfragment says."skip", with a timer at the stated end and the end at least a second on. It's what keeps the batch. Waking under"skip"would change what"skip"means. Waiting for the next turn's end could leave the batch for hours. The second's floor sits in the refusal's end itself, so a turn's end or a new batch can't ask again at once either.previewActivation, a compile of the agent's context, before the framework refuses it. Under"wake"the operator chose to wake when the judge can't answer, so the batch wakes unjudged; under"skip"it joins the held ones.providerCallsReleased(), called where an operator releases a provider wait (always, since the wait may have lifted unannounced) and where a surgery lets go of the store. Those holds state no end, or an end the release comes before. A provider hold on the agent is asked about again at its stated end by the refusal's timer, and one its host extended refuses again with the new end, so its release isn't wired.wakesOnTheirWay. One reading for the wake list and the failed-turn line, so the judge isn't told a wake is held in one and that nothing is scheduled in the other. Two of its sources each have a test that fails without them: a wake a scheduler pass still holds while it waits for an earlier agent's start, and a start that waits behind an auxiliary call with nothing queued. A provider hold left holding nothing, by a drop or by a withdrawal of its held wakes (which came tomainwith fix(scheduler): a merged start that goes back before its turn is merged again from its wakes #877), counts as holding a wake only where it holds back a queued request, since its own end starts no turn. Two conditions on a provider hold each have a test that fails without them. A re-arm doesn't hold a wake queued while the agent waits for it (src/framework.ts:6090). Without that condition, "a wake queued while the agent waits for a re-arm is what the judge is told of, not the re-arm's end, since the wake starts the turn at once" fails. A hold that holds nothing yet still holds a queued wake (:6092). Without that, "lists a wake queued behind a hold that holds nothing yet as held until the hold ends, since a scheduler pass holds it there" fails.wake_remove_rule's first sentence for an agent with the judge (src/framework.ts:13032–13033). The settings toolset shows a tool by its first sentence, so that sentence says a judge rule's pending batches still go to the judge, by the rule as it was. After it comesWAKE_REMOVE_RULE_REST(:12884), the rest of the description every agent gets (:13016), so the two descriptions can't drift apart there. With4b6c4c1438's source, whose judged description said it last, the test that checks the first sentence (test/event-gate-judge.test.ts:1530–1532) fails (Tests).failedLastTurnFor. On two requests an earlier version of this change built, with the line's "nothing runs it again" ending, the judge's model answered REPLY 10 times of 10 on each, and WAIT 10 times of 10 on each without the line (Not verified says what was measured). The endings say only what the framework's state shows, and never that a turn runs.UpcomingWake.when. Without it, a wake due at no nameable time would read "as soon as it is free", or be left out, and the list would then say nothing is scheduled while the failed-turn line said a wake was held. A batch held for the judge under a refusal with no stated end has thewhen"once a later judge call gets a REPLY or WAIT, or the hold on its calls is released, and no turn or quiesce holds it" (src/gate/event-gate.ts:4005), since a verdict on a call asked before the refusal doesn't end it. With4b6c4c1438's source, the test of thatwhenfails (Tests).upcomingWakesBut). The judge's request leaves out only the batch it asks about.upcomingWakesleaves out the rule it's given, its batches and buffered events alike, so its signature stays 0.28.0's. Without leaving out the asked batch, the judge is told about the batch it's deciding. Asked with its rule's name, as in 0.28.0, the judge isn't told its own rule's next batch. Without the given rule left out of each kind,upcomingWakeslists that rule's batch being collected, decided or held, or its buffered events.when. Without the entry, the judge isn't told about events already waiting while a turn runs or the host is quiesced. They're delivered once no turn runs and the host isn't quiesced, so the entry lists them as certain. It leaves out an event whose channel is held when the list is made. One predicate,heldNow, decides both which events delivery drops for a held channel and which ones the entry leaves out. Without it, the entry calls a wake certain that delivery would drop, and the test "leaves out of the buffered count an event whose channel is held now" fails. That condition, and the one for a batch held for the judge during a turn or the host's quiesce, are each awhen, since a wake with neitheratnorwhenreads as due as soon as the agent is free.test/event-gate-judge.test.ts:236);:766), the held batch's last 40 events and the 60 new ones, so the cap can't keep the wrong hundred;:1939), so a re-arm's held ending doesn't take the place of its "nothing runs it again" one, and the held line, with its due time, inexhausted-turn-rearm's judge test (test/exhausted-turn-rearm.test.ts:695).ask()."skip"the agent doesn't wake for its judge rules meanwhile.ask()is the one place the judge's call is made, so the first ask and the retry send the same way.failedLastTurn: a provider that throws counts as no failed turn, asupcomingWakesdoes.event-gate-judge. The file's tests step the gate's own timers (the debounce, the refusal's one-second floor and a stated end) onnode:test's mock timers (setTimeout,setIntervalandDate) rather than wait them out. A test that asserts nothing happened by a deadline advances the clock past it. Fixed waits step the clock 5 ms at a time, and a wait for a condition, or for the framework to go idle, steps it until the condition holds and fails past a bound on the mocked clock. One test stays on real time, the MCPL end-to-end test, since it waits for a child process's replies, which arrive in real time.refusingMembranerefuses chosen requests as too long,judgeOverbuilds a judge over a conversation and budget a test chooses (the fresh-window test needs a conversation that fits the halved budget's anchored window), andquietlysilences the gate's log while a test expects one. feat(gate): judge wake rules — ask a small model before waking #801's judge timeout test now waits for the timed-out judge's wake instead of sleeping a fixed 200 ms, which a loaded full suite overran. The failed-turn test's release step asserts the retry ran, by the failure count rising.scriptedJudgerecords when each request was asked, so tests assert on the time of an ask rather than sleep through windows.answerAtandfailAtsettle one waiting request out of order.makeGate'sonTracelets a test act as a trace listener, andlogDuringcollects what the gate logs while a step runs.judgeStateandrefusalshorten what every refusal test repeats. feat(gate): judge wake rules — ask a small model before waking #801's test of the gate's own timers now checks the self-wake's time exactly, where it allowed 2 s either way.Number.isFinitecheck and a second refusal check before the held flush is queued, which the Date bound and the flush's own check cover;src/framework.ts:6884), each place that makes one passes it a wait recorded or looked up for the agent's own model (:6490,:6628,:19485,:23235and:24238), and the agent's hold is deleted when its runtime is released (:11970).Tests
This head,
0342d90c75, changes only the.changedand.fixedfragments froma4e345bd7e, so the runs below ata4e345bd7eran this head'ssrc,testandpackage.json.a4e345bd7e, whose last merge ofmainbrought in612f6fd3bd, on Linux with Node 24.22.1, chronicle 0.5.0, context-manager 0.24.1, mcpl-core 0.4.0 and membrane 0.8.0, from a clean checkout and a fresh install:tscand the build exit clean;event-gate-judge100 of 100,reply-judge33 of 33,provider-acceleration-cooldown10 of 10,provider-waits93 of 93,held-turn-retry66 of 66 andhost-quiesce51 of 51.59d60958), on Linux with Node 24.22.1, context-manager 0.23.2, membrane 0.8.0 and chronicle 0.4.0. They're run with this change'ssrc/supplied-text.tsand thesrc/notice-tags.tsit imports added, since the tests import the first, and with an empty stand-in forfailedTurnLine, which 0.28.0 lacks.tscreports 14 errors, among them that 0.28.0'sFrameworkConfighas noexhaustedTurnRearmMs, and the tests run from the emitted JavaScript.event-gate-judge: 51 of 100 fail. 48 fail at an assertion: 42 on the behaviour this change makes, 5 only on therefused: 0the count now carries, and 1 on how fix!: mark the framework's notice openings in others' text, and keep supplied values on one line #852, merged intomainafter 0.28.0, writes a judge's reason. 3 fail on what 0.28.0 lacks: 1 onproviderCallsReleased, and 2 at a test helper that readshostPresentation.builtinTools. The test that a wait on an event outside MCPL writes no message passes with 0.28.0's code too.reply-judge: 14 of 33 fail. 5 of them are too-long retry tests: 3 fail at an assertion, and 2 when the provider's "prompt is too long" rejects the judge's one call. The other 9 fail at an assertion: 8 on the other behaviour this change makes, and 1 on the stand-in. The sixth retry test, that no other failure is asked again, passes with 0.28.0's code too.wake_remove_rulecheck against the merge before it: this head'sevent-gate-judge.test.ts, run with the source of4b6c4c1438, this change's merge ofmaine85c57db9abefore itswake_remove_rulecommit, on Linux with Node 24.22.1, context-manager 0.23.2, membrane 0.8.0 and chronicle 0.4.0, passes 96 of 100. One test that fails is the one that checkswake_remove_rule's first sentence, which reads "Remove a wake rule (gate.json policy) by name at runtime." there. Another checks thewhenof a batch held under a refusal with no end, whose wording this change set later. The other two test what came tomainaftere85c57db9a: a hold emptied by a withdrawal of its held wakes (fix(scheduler): a merged start that goes back before its turn is merged again from its wakes #877), and a buffered event on a channel held at delivery (feat(dendrite): one registry, lifecycle and fork primitive for agents; admission, reader forks, the all-except scope and speech.locus (#232, #77, #270) #546).a4e345bd7e, on Linux with Node 24.22.1, chronicle 0.5.0, context-manager 0.24.1 and membrane 0.8.0,event-gate-judgepasses 100 of 100 andexhausted-turn-rearm84 of 84.exhausted-turn-rearm's judge test checks that the judge is told a re-armed turn's wake is held. WithfailedLastTurnFormade to answer that nothing runs the turn again where it would say a wake is held, each file fails one test: inevent-gate-judge, the test that the judge is told the last turn failed and whether a wake is on its way, alike in the line and the wake list, and inexhausted-turn-rearm, its judge test.a4e345bd7eon Linux with Node 24.22.1, chronicle 0.5.0, context-manager 0.24.1 and membrane 0.8.0: with the list ignoring the wakes a scheduler pass holds,event-gate-judgepasses 99 of 100, and only "lists another agent's wake that a scheduler pass holds while it awaits an earlier agent's start" fails. With no wake for a start waiting behind an auxiliary call, it passes 99 of 100, and only "lists a start that waits behind an auxiliary call, with nothing else queued, as a wake on its way" fails. Each fails at its last assertion, with the list empty.exhausted-turn-rearmpasses 83 of 84, and only "a wake queued while the agent waits for a re-arm is what the judge is told of, not the re-arm's end, since the wake starts the turn at once" fails. Without the clause that a queued wake is held behind a hold that holds nothing yet,event-gate-judgepasses 99 of 100, and only "lists a wake queued behind a hold that holds nothing yet as held until the hold ends, since a scheduler pass holds it there" fails.72141a75b8, this change's last commit before its merge of 0.28.0, each of 62 parts was taken out in turn and the two judge files run. 59 removals each failed at least one test. The three that failed none were holding a refused batch before its trace is emitted rather than after, where the tests pass in either order, and two parts of the "queued" ending: a start waiting for an auxiliary call, and a queued request beside a hold that doesn't bind it. At this head the first of those has a test, and the second is cut from the code ("What each part is for"). The run was on Linux with Node 24.22.1, and its receipt doesn't record which packages were installed. At03422d5948, from a clean checkout on Linux, 11 removals of parts of the judge's list of upcoming wakes and of the refusal's log line were run the same way. Atfbf84e01f6, 6 more were: four of those again, the buffered entry'swhen, and the cap on the batch an ask makes when it takes in its rule's held batch. At92b0d3ca48, the commit before this change's merge ofmainat65bf22bc0a, the buffered entry'swhenwas taken out again, and so was thewhenof a batch held during a turn or the host's quiesce. Each of these 19 removals failed at least one test. None of the three receipts records the Node version or which packages were installed.event-gate-judge, at72141a75b8, on Linux with Node 24.22.1 and no added load: five runs of that commit's file took 3.4 to 6.4 s of wall time, and five runs of8e54e6dd59's version, on real timers and interleaved with them, took 28.3 to 33.3 s.test/event-gate-judge.test.ts:onErrorand per kind, logged with where its batch goes, held, judged at the end, and held again;"wake";upcomingWakesleaves out when given a rule's name;test/reply-judge.test.ts:Out of scope
mainwith feat(turns): send a turn's trigger again when its retries run out on a transient failure, with delays of 2, 8 and 32 minutes, and alert on an hour of stated waits #846, and this head carries it. This change only tells the judge about such a turn. With re-arming on, as it is by default, the failed-turn line says a wake for the agent is held, or queued once another wake for it is queued.Not verified
claude-haiku-5-5. The calls went through membrane 0.7.2'sAnthropicAdapter, with Bun 1.4.4 on Linux.8e54e6dd59, with membrane 0.8.0 and Node 24.22.1, and sent between 10:33 and 10:35 UTC on October 27.parseVerdictreads it. One answer to the partial request opened with WAIT and ended with REPLY.[inference-failed]marker has the wording from before fix(turns): name unposted speech, say whether tool rounds ran, and say when live-routed speech got no [delivered] line #840, which sets the marker's sentences by what the failed turn delivered.<) in the document wasn't measured.context_lengthrefusal through the framework'sjudgeMembraneFor, so whether the retry sees its type there rests on reading the code.consecutiveInferenceFailuresandlastInferenceAt, read atsrc/framework.ts:6102-6103).flushJudgeHeldIfClear, which waits while the host is quiesced or a turn that changes what the judge reads is running (src/gate/event-gate.ts:4245-4246). So a host that callsupcomingWakesduring such a turn can read a time that has passed.changelog.d/reply-judge-review.breaking.md,changelog.d/reply-judge-review.added.md,changelog.d/reply-judge-review.changed.mdandchangelog.d/reply-judge-review.fixed.md.Rupert-2634 wrote this change, and Ada-1017 made its design calls. Desmond-2579 read an earlier version of it, before its merges of
mainand the too-long retry, and fourteen rounds of internal review read earlier versions of it. Duncan-2410 read its last commit before its merge of65bf22bc0a, Gordon-2414 read that merge, and Alfred-2570 read its merge ofe85c57db9a. Alfred-2570 also read its merge of4a45766f01, except where that merge meets his own commits onmain, which Naomi-2422 read. Crispin-2713 weighed what each source of the wake list pays for, which led to1a34d6b690, and read that commit word for word. He also read2ca6e5b0a1's changes word for word, before Rupert-2634 added one clause to its changelog fragment. Naomi-2422 read its merge of612f6fd3bd,a4e345bd7e, and Crispin-2713 read its last commit, which rewords the fragments against 0.30.0. Imogen-2490 wrote the harness behind the live measurement and ran it on requests an earlier version of this change built.Fiona-2757 wrote this description from Rupert-2634's draft.