Skip to content

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

Open
nissa-seru wants to merge 97 commits into
anima-research:mainfrom
nissa-seru:fix/reply-judge-refused-calls
Open

nissa-seru wants to merge 97 commits into
anima-research:mainfrom
nissa-seru:fix/reply-judge-refused-calls

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Why. The reply judge, added in #801 and released in agent-framework 0.28.0, drops a batch whose judge call the framework refuses to send under onError: "skip", isn't told when the agent's last turn failed, and lets participant text make the structure of its document. @Anarchid's first review of #801 found these and more.

This answers that review: the gate records a judge call the framework refuses and, under "skip", holds its batch; the judge is told of a failed last turn; and participant text is escaped. Each item in the details that answers a finding names it, as R1-n from the review.

A judge call the framework refuses to send

  • base (main at 36b6dfe9de): under onError: "skip" the gate drops the batch, and under either setting nothing traces or counts the refusal.
  • head: the gate records a verdict of refused, and under "skip" holds the batch until it can be judged. With the framework's own judge, a call the framework would refuse reads none of the agent's context. This is breaking for code that handles each value of the verdict: 'refused' joins judgeState.last.verdict and the gate:judge-verdict trace's verdict.

The judge after the agent's last turn failed

  • base (main at 36b6dfe9de): the judge's next request says nothing of the failure beyond any [inference-failed] marker the failed turn left in the agent's context.
  • head: the judge is told when the agent's last turn failed.

Participant text in the judge's document

  • base (main at 36b6dfe9de): message text and event content go in as written, so a participant can write a line that reads as another participant's message, or close <new_events> and open a tag of their own.
  • head: they're escaped (&, <, >), with each line after the first indented.

A judge request the provider refuses as too long

  • base (main at 36b6dfe9de): it fails as any failed call does, and isn't asked again.
  • head: it's asked once more, from a fresh window, at one character a token.

A batch still in its debounce window when gate.json is reloaded with its rule edited

  • base (main at 36b6dfe9de): the reload sends it to the judge it was batched with, its rule's from before the edit.
  • head: the reload sends it once the edited rules are in force, and it's judged by its rule as the rule is when the judge is asked, while it's still a judge rule.

Other pending batches, in a judge request

  • base (main at 36b6dfe9de): <if_you_wait> doesn't list other judge rules' batches being decided or held, or events buffered behind a running turn.
  • head: a judge request lists every pending batch but the one it asks about.

Upcoming wakes in healthSnapshot(), which 0.33.0 added

  • base (main at 36b6dfe9de): each agent's list holds its own self-wake and sleep timers, its rules' batches in their debounce windows, and the framework's queued requests, provider cooldowns and heartbeat.
  • head: it also holds the wakes this change adds to the judge's list, and leaves out any wake due at no time anyone can name.

It also corrects texts in the code that said more than the code does, and a changelog fragment corrects 0.28.0's notes on the judge where they did too. It doesn't take four of the review's suggestions: a lower starting ratio and keeping the ratio with the usage state (R1-4), since the review puts the cold-start overshoot at cents; any change for R1-8, so a judge request's usage still counts as the judged agent's, or for R1-9, so the judge stays on unless the host turns it off; and a system-prompt sentence for R1-3, since escaping and indenting answer it and the sentence's effect on the model hasn't been measured.

It changes twelve documented rules, in the gate's and the judge's types, EventGate.upcomingWakes, the reply judge's header and two tool texts, quoted below.

The documented rules this changes

The verdict of a judge call (judgeState.last.verdict, and the gate:judge-verdict trace's verdict)

  • base (main at 36b6dfe9de): 'reply' | 'wait' | 'error'
  • head: 'reply' | 'wait' | 'error' | 'refused'

judgeState.verdicts

  • base (main at 36b6dfe9de): "Verdicts so far: reply (the batch sent on to delivery to wake the agent, as JudgeConfig says), wait (the batch let go by), and judge failures.", as { reply, wait, error }
  • head: "Verdicts so far: reply (the batch sent on to delivery to wake the agent, as JudgeConfig says), wait (the batch let go by), judge failures, and calls whose request the framework refused to send.", as { reply, wait, error, refused }

judgeState.heldCount

  • base (main at 36b6dfe9de): "Events held for the judge until the agent's turn (or the host's quiesce) ends."
  • head: "Events held for the judge until the agent's turn (or the host's quiesce) ends, or a refusal's hold ends."

The gate:judge-verdict trace's woke

  • base (main at 36b6dfe9de): "woke is whether the gate goes on to deliver the batch to wake the agent (an error's is by the host's onError). JudgeConfig says when such a delivery starts no turn."
  • head: "woke is whether the gate goes on to deliver the batch to wake the agent (an error's or a refusal's is by the host's onError: under "skip" a refused batch is held to the refusal's stated end or a release, then goes back to the judge, and not every refusal of a held batch is traced, as ReplyJudgeOptions.onError says). JudgeConfig says when such a delivery starts no turn."

ReplyJudgeOptions.onError

  • base (main at 36b6dfe9de): "What a judge that fails, times out or answers unreadably does: "wake" wakes the agent as a debounce would (the default, so a judge outage never silences the agent); "skip" doesn't."
  • head: the same, then "It decides a call whose request the framework refuses to send too (a hold on the agent's provider calls or on the judge's model, a turn of the agent's waiting for compression, a surgery's hold on the store, or a stopping framework), which isn't counted as a failure. Each batch is asked about.", then what each setting does with a refused call's batch, and which of a held batch's refusals are logged and traced.

A judge rule's WAIT (JudgeConfig)

  • base (main at 36b6dfe9de): "on wait the agent isn't woken: the events are in its context already, as for defer, and the next batch is judged afresh."
  • head: "on wait (or a failure under "skip") the agent isn't woken, and the next batch is judged afresh. The events are in its context either way when they're stored as they arrive, as MCPL's channel and push events are (apart from a coalesced push its server sends deferred, which waits outside the context until the server renders it for a turn), and as the framework's own modules store the message an event of theirs carries; a wait, like defer, adds nothing more."

The judge's input budget (ReplyJudgeOptions.maxInputTokens, and the reply judge's header)

  • base (main at 36b6dfe9de): "The most input tokens one judge request may carry, …", and in the header, "The budget (maxInputTokens, default 90,000) keeps every request under the 100,000-token line past which Claude Haiku 5.5 costs five times as much."
  • head: "The budget of input tokens for one judge request, …", which, like the header, now says the budget is met by an estimate: "a request on text denser than the ratio can carry more than the budget: code, or Chinese, Japanese or Korean text, before a response has reported on such text or after the ratio has followed sparser text. So can one whose newest message alone is over it, since that is always shown."

UpcomingWake.at and UpcomingWake.certain

  • base (main at 36b6dfe9de): "When (ms), when known; absent for one due as soon as the agent is free." and "False when it only may wake the agent: a judge rule's batch goes to the judge then, and a debounced batch whose events delivery gives the agent each have a message of its own written to its window (WakeProvenance.writes), … starts no turn once the agent has read and answered them."
  • head: "When (ms), when known. Absent for one that has no time of its own: a wake already queued, due as soon as the agent is free, and one when describes, …" and "False when it only may wake the agent: a judge rule's batch goes, or may yet go, to the judge then, and a debounced batch, or events buffered for delivery, whose events delivery gives the agent each have a message of its own written to its window (WakeProvenance.writes), … starts no turn once the agent has read and answered them."

JudgeRequest.upcomingWakes

  • base (main at 36b6dfe9de): "… their self-wakes, a sleep's end, other rules' pending batches. The judge adds what the framework knows besides."
  • head: "… their self-wakes, a sleep's end, every pending batch but this one, and events buffered while a turn runs or the host is quiesced. The framework's own judge adds what the framework knows besides."

EventGate.upcomingWakes

  • base (main at 36b6dfe9de): "… and the pending batches of rules other than exceptPolicy. A debounce's batch wakes the agent when it fires. It only may where each of agentNames is given none of its events, or each event delivery gives that agent has a message of its own whose write was placed for it. … A judge rule's batch goes to the judge, so it only may."
  • head: "… and the pending batches of rules other than exceptPolicy, with those rules' events buffered while a turn runs or the host is quiesced. A debounce's batch wakes the agent when it's delivered: when it fires, or, buffered, once no turn runs and the host isn't quiesced. Either only may where each of agentNames is given none of its events, or each event delivery gives that agent has a message of its own whose write was placed for it. … Without a judge wired, a judge rule's batch is delivered as a debounce's is, and listed so. With one, a judge rule's batch goes to the judge, whether it's collecting, being decided (with what queued behind it) or held, so it only may. Under onError "skip", one held because its call was refused is listed at the refusal's stated end, and past it while a turn or the host's quiesce holds it, which the entry says while one does."

wake_add_rule's judge parameter, as an agent reads it

  • base (main at 36b6dfe9de): "When it says wait, you aren't woken (the events stay in your context, as for "defer"); the next batch is judged afresh."
  • head: "When it says wait, you aren't woken, and as for "defer", nothing more is added to your context: what was stored as it arrived stays stored for when you're next woken. The next batch is judged afresh."

wake_remove_rule, as an agent with the judge wired reads it

  • base (main at 36b6dfe9de): "… A judge rule's batch, whether waiting out its quiet period, held or being judged, still goes to the judge, by the rule as it was."
  • head: "Remove a wake rule (gate.json policy) by name at runtime; a judge rule's pending batches still go where the rule as it was sends them. … A judge rule's batch is pending while it waits out its quiet period, is held or is being judged."

What hasn't run: the failed-turn line's effect on the judge was measured on claude-haiku-5-5 with four of its five endings, the queued one, the one that says nothing runs the turn again and the two that say a wake may be dropped, but not the one for a certain held wake, and how a model reads the escaped text wasn't measured. No test sends a too-long refusal through the framework's judge path or restarts the framework after a failed turn, and other paths rest on reading, as the details' Not verified says.

  • Changelog fragments added: changelog.d/reply-judge-review.breaking.md, changelog.d/reply-judge-review.added.md, changelog.d/reply-judge-review.changed.md and changelog.d/reply-judge-review.fixed.md.
Credits

Rupert-2634 wrote this change, with one round by Barnaby-2576, and Ada-1017 made its design calls. Esther-2584, Barnaby-2576, Desmond-2579, Duncan-2410, Gordon-2414, Alfred-2570, Naomi-2422, Crispin-2713 and Nigel-2405 read or weighed its rounds and merges, Daniel-2612 and Barnaby-2576 ran cases that became its tests, and Imogen-2490 built the live measurement. Alfred-2570's rent pass, from a least he wrote before reading the change, proposed its smaller refusal state and failed-turn reading, which Rupert-2634 built on Ada-1017's calls, with Octavia-2412's measurement of a compile and live judge calls behind them, and Abigail-2676 read the rebuild. Cuthbert-2453's check of the final state proposed asking the framework before reading the agent's context, one helper for the failed-turn mark and one escape for a bracket in a name, which Rupert-2634 built on Ada-1017's calls, and Cordelia-1930 read that round, the round answering its automatic review, the rewrite of the branch's commit messages, the catch-up onto main at 9d57cc8138, the round meeting main at e0fa00f666, where her reading added its stop test, and the catch-up onto main at 36b6dfe9de. George-2477 checked where each part sits in the framework. Fiona-2757 and Rupert-2634 wrote the description, Pippa-2697 found a point in it, and Ramona-2416 put it in this shape.

Details for agents

Head 140af4c73daf, into main. It carries merges of main at 9d57cc8138, at e0fa00f666 (0.34.0) and at 36b6dfe9de (#972), the last of which the top's pairs take as their base. At the first, a refusal's notice in gate_status stopped quoting the hold, since main now tells an agent a failure in the framework's words (#1000): at 733e82ecba it read "Rule "NAME"'s reply judge call was refused (HOLD) until END: N events held for the judge.", and here it reads "Rule "NAME"'s reply judge call was refused until END: N events held for the judge."

Problem

The review, at b296996, found these in #801, which has since merged and was released in 0.28.0.

  • R1-1. When the framework refused to send a judge call's request (a provider hold on the agent or on the judge's model, a surgery's hold on the store, a stopping framework), the gate dropped the batch under onError: "skip". Under either setting, nothing traced or counted the refusal.
  • R1-2. When a turn the judge's REPLY woke failed with its retries spent, the judge's next request said nothing of the failure beyond any [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).
  • R1-3. Message text and event content went into the judge's document as written, so a participant could write a line that reads as another participant's message, or close <new_events> and open a tag of their own.
  • R1-4. A request the provider refused as too long failed as any failed call does, and wasn't asked again. Nothing lowered the estimate's ratio after the refusal: it's corrected only from a response (calibrate, called at src/gate/reply-judge.ts:488 in 0.28.0).
  • R1-5. A batch pending when gate.json was reloaded was judged by its rule's instructions from before the edit.
  • R1-6. <if_you_wait> didn't list other judge rules' batches being decided or held, or events buffered behind a running turn.
  • R1-4, R1-7 and R1-10, in texts. Texts said more than the code does:
    • the judge's header said the budget keeps every request under the 100,000-token line, and the maxInputTokens doc called it the most input tokens one request may carry;
    • the header and the changelog said the judge reads the request the agent would send, with no word that the dry run leaves out injections;
    • the changelog said the judge always uses membrane's native formatter;
    • the cacheTtl doc didn't say that Bedrock drops it.

Changes

  • A refused call (R1-1). When the framework refuses to send a judge call's request, the gate records a verdict of refused. For a call asked once more after the provider refused its first request as too long, the request refused is the second. The verdict is:

    • counted in the rule's judgeState.verdicts.refused (refused calls, not batches), apart from errors, and outside the three-failures streak, which it neither adds to nor ends;
    • traced as gate:judge-verdict with verdict: "refused", the hold's words (hold) and its stated end (until), when it stated one;
    • with the hold's words marked as supplied text, on one line and cut to 300 characters, in the trace and the log line, since a provider wait's reason is text from outside the framework;
    • in the framework's words wherever the agent reads it: gate_status names no hold, and judgeState.last.reason is "the framework refused to send the call", as main gives a failed call's reason in its own words (fix: a failure is told in the framework's words, never the provider's #1000), since a hold's words can carry a provider's;
    • under "skip", named in gate_status's errors for each rule whose batch it holds, in the rules' order, as "Rule "NAME"'s reply judge call was refused until END: N events held for the judge.", with the end only when the refusal stated one (src/gate/event-gate.ts:5298–5313). Under "wake" nothing is held, so gate_status names no refusal, and the rule's judgeState.last shows it;
    • logged with where its batch goes: it "is held while the judge's calls are refused" under "skip", or "goes to delivery without the judge" under "wake".

    Under "wake" each refusal is logged and traced. Under "skip", a held batch's first refusal is logged and traced, and so is its first after a release or after a turn stops a call that took it in; the rest are only counted (refusedBefore, src/gate/event-gate.ts:4620).

  • Asking before the context is read (R1-1). The framework's judge asks the framework whether it would refuse the call on the judge's model for the agent it reads (ReplyJudgeDeps.refusal, src/gate/reply-judge.ts:110) before it reads that agent's context (:632–634). The framework answers with judgeRefusalFor (src/framework.ts:7135), the five checks its membrane's admission makes: a surgery's hold on the store, a stopping framework, a provider wait on the model, a turn of the agent's that waits for compression (:7147), and an acceleration hold. The wait for compression is fix: a turn refused for budget waits idle until compression makes room #361's: a turn whose compile was refused for budget waits idle. The judge's preview would be a dry compile of the window that turn couldn't send, so the call is refused rather than asked, and under "skip" its batch is held to the wait's end. A call it would refuse reads none of the context, sends nothing, and rejects with the error the membrane's admission would throw, so the gate decides it the same way. The membrane's admission still makes those checks (src/framework.ts:7126), so a call whose hold began while the context was read is refused there. A judge built with deps that leave the hook out reads the context and leaves the refusal to the call. The judge picks the agent once, and the check and the build both take it. It asks again before it rebuilds a request refused as too long, so a hold that stands then stops the retry before its preview.

  • Each batch asked, and a refused batch held on its own (R1-1). The gate keeps no refusal of its own: each judge rule's batch goes to the judge as any batch does, whatever refusals stand.

    • Under onError: "wake", a refused call's batch, with the events queued behind the call, is delivered as a debounced batch is, delivery dropping events on a held channel, and nothing is held.
    • Under "skip", it's held in memory with the refusal it got (holdForJudge, src/gate/event-gate.ts:4384), its rule keeping its newest 100 events, and a timer of its own at the refusal's stated end (:4658). It goes back to the judge at that end, or once the framework says a hold ended or was released, when no turn that changes what the judge reads runs and the host isn't quiesced. A refusal that states no end, or no time left, which an acceleration hold past its end gives until the framework's timer lets it go, holds it until such a release.
    • The rule's next batch takes the held batch in and is asked, so the rule's events stay in the order they came. One that fires while a turn or the host's quiesce holds it ends the held batch's refusal, so the whole is asked when the turn or quiesce ends. Refused too, the whole is held to the new refusal's end. If its call fails, under "skip" its batch goes as a failed call's batch does, with the held events it took in. A held batch its rule adds nothing to waits through turns' ends.
    • A refusal reported by a call that was out when the framework said a hold ended doesn't hold its batch: under "skip" it's held without the refusal, so it's asked again once no turn or quiesce holds it.
    • A REPLY, a WAIT or a failure on one rule's batch leaves another rule's held batch held.
    • An operator's release of a provider wait, a surgery's end, an acceleration hold's end and the end of a turn's wait for compression release a held batch (EventGate.judgeRefusalsMayHaveLifted()). The wait tells the gate however it ends, released, stopped or removed, since each ends in finishBudgetHold (src/framework.ts:7771). The framework says a hold ended where it deletes one while the gate runs: at its timer's release (src/framework.ts:8018), at the scheduler's release of one whose timer is late (:22907), and when an agent ends, whether or not it was held, since the judge may then read another agent (:13402). At a stop it clears every hold (cancelProviderAdmission, :8369–8371, from stopOwned at :6445), and the gate stops with it. A refusal by a provider wait with an end states its time left from the same reading of the clock that found the wait live (:8253–8260), so it always states some. The gate's timer asks again at that end, and the wait has lapsed then unless it was extended since, when the call is refused again with the new end. A provider wait with no end, such as the one that stands while recorded provider waits can't be read, states no time, so its refusal is one with no end.
    • A hold that lifts unannounced, as recorded provider waits read again after they couldn't be, leaves the batch held until its rule's next batch or a release.
  • A failed last turn (R1-2). When the agent's last turn failed, with its retries spent or by a throw before its stream (at its checkpoint, locus resolution or typing indicator), and no turn has started 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, by the kinds of wake in the list the judge is given (src/gate/reply-judge.ts:284–297), one of five things:

    • with a certain queued wake listed, that a wake for the agent is already queued;
    • else, with a certain provider-hold wake listed, that one is held, due to be let go at the first such wake's time, in the list's order, when it has one;
    • else, with a queued wake listed as only maybe, that one is queued, but it may be dropped without running a turn, and if it is, anything that woke the last turn and hasn't been answered stays unanswered until something else wakes the agent;
    • else, with such a provider-hold wake, that one is held, due to be let go at its time when it has one, with the same "may be dropped";
    • else, that nothing runs the turn again, so whatever of it is unanswered stays so until something wakes the agent.

    The ending is chosen from the list the judge is given, so the line and the list can't disagree. The framework's own entries are main's upcomingWakesFor (src/framework.ts:6893–6912): requests waiting for the scheduler, listed as queued, a provider cooldown that holds requests and has a finite end, listed as a held turn at that end, and its heartbeat's next tick and pending reminders. Each of the first two is certain: false where every request in it reads rows, or is a start merged from wakes that each do (droppableAsRead), since main's scheduler drops such a wake once the agent has answered them (fix(gate)!: a debounced batch's wake starts no turn once the agent has answered every event in it #972), and certain: true otherwise. The gate lists neither kind. A start the framework's list misses, such as one parked behind an auxiliary call, one a scheduler pass holds, or one assembling its deferred pushes, gives that ending too, and the judge's REPLY can then draw a second turn. Neither of the other endings says a turn will start, since the inference policy can decline a wake. The mark that the agent's last turn failed is kept for the agent itself and read through the name's binding (src/framework.ts:4457, :6916). The five places that end a turn exhausted mark it through one helper, failTurn (:13998), which settles the turn, marks it and traces inference:exhausted, as each place did itself before. They are a start that throws before its stream (:27638), silent or not, which settles only one that isn't silent and traces nothing, a start that fails for good before its stream (:28804), and the stream's three places that end a turn exhausted (:30752, :30837, :31142). A start clears its own agent's mark once it starts inference (:28406). The framework gives the judge the time through ReplyJudgeDeps.failedLastTurn (type FailedLastTurn, src/framework.ts:5697, :6914–6918).

  • Participant text in the document (R1-3). Message text and event content are escaped (&, <, >), with each line after the first indented. A [ that opens a line of a message's text, of a tool's result or of an event, after any spaces or invisible or control characters, is escaped with a backslash, while the message's own block labels ([calls …], [… returned]), which the renderer writes, aren't. A tool's name in such a label is kept to one line, with its square brackets and backslashes escaped with a backslash (labelName, src/gate/reply-judge.ts:224–226), so a name can neither open a line of its own nor close its label, and a backslash in it can't undo an escape. A tool call's input is shown as its JSON, on one line: U+0085, U+2028 and U+2029, which JSON keeps as they are and the quoting would take for line breaks, are written as \u escapes, and an input JSON can't hold, or gives nothing for, such as undefined or a function, is shown as its text, with a line-opening [ escaped as in a tool's result. A message too long for the document is cut before it's quoted, each character counted at its quoted width, so the cut splits no escape or surrogate pair, its marker is indented like the message's other lines, and the rendered message stays within its 12,000 characters. That bound counts the participant's name too: a name too long to leave the text a cut marker's room is cut at its end, at its escaped width, without splitting a surrogate pair. A participant's name is kept to one line, with square brackets and backslashes escaped with a backslash, as a tool's name is, and with the entities (field, src/gate/reply-judge.ts:148–150): a participant named Ann [mod] is labelled [Ann \[mod\]], where main labelled it [Ann [mod]]. A tool's name takes the backslash escape alone, since the message's quoting gives it the entities. The judge's prompt names the agent's participant as its messages are labelled (:351). An event's channel and its author are each kept to one line and quoted as a JSON string, so neither can read as the other, with the entities and no other escape (:378–379): inside the quotes, mid-line, a bracket can't open a label, and JSON's own escape already doubles a backslash. 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 batch whose rule a change removes, or makes another kind, goes as it was batched. A judge rule's batch takes the judge's path, held or delivered without the judge as any judge rule's batch is when its call is refused, and is judged by the instructions it was batched under, or by those of the call of its rule it waited behind (currentJudge, src/gate/event-gate.ts:4429–4432). A debounce rule's batch is delivered without the judge. 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; judgeBatch reads the rule then. What that order means for a host whose own trace, changesJudgedContext, getAgentNames or clock throws is in Not verified.

  • Every pending batch (R1-6). A judge request lists every pending batch but the one it asks about. Besides what 0.34.0 lists, 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.
    • The .changed fragment tells a host that gives an EventGate a judge function of its own that an UpcomingWake without at is no longer always due as soon as the agent is free.
    • Held batches are asked about in turn, each leaving the held set as it's asked, so each request lists the rest.
    • Under "skip", upcomingWakes lists a batch held for a refusal at the refusal's stated end, with the turn or the host's quiesce that holds it past the end named while one does (src/gate/event-gate.ts:4496–4501), and one whose refusal stated no end with the when "with its rule's next batch, or once the hold on its calls is released, and no turn or quiesce holds it". A judge rule's batch still in its quiet period is listed as going to the judge, with certain: false, whatever refusals stand, and as joining its rule's held batch when there is one. With no judge wired, it lists a judge rule's batch as debounced, since it's delivered as a debounce's is.
    • The list shows eight, soonest first, and its last line says how many more there are and how many of those are only maybe.
    • A wake due at no time anyone can name says when in words (UpcomingWake.when) and comes last, in the judge's list and in upcomingWakes. The gate lists a batch the judge is deciding as due "once the judge decides it".
    • Each wake's words, and its time in words, are kept to one line with the entities, and no other escape: mid-line, a bracket opens no label, and the gate's words already quote a rule's name as JSON.
    • Each entry this change adds to the gate's list is listed only for an agent among the gate's recipients (src/gate/event-gate.ts:4471, :4489, :4494, :4504), as main lists a rule's batch in its debounce window (main at 9d57cc8138: src/gate/event-gate.ts:4444, :4449). Each entry this change adds carries main's kind, for what it is: 'judge-batch' for a batch that goes to the judge or may, and 'debounce' for buffered events (src/gate/event-gate.ts:4485–4505).
    • healthSnapshot()'s upcomingWakes, which 0.33.0 added, takes the framework's and the gate's lists as before and leaves out a wake with a when (src/framework.ts:6942), since each of its entries gives only a time, a kind and whether it's certain.
  • A request refused as too long (R1-4). A request the provider refuses as context_length is asked once more, from a fresh window, at one character a token, the lowest ratio the estimate takes (calibrate's floor), unless it was built at that already. Refused again, it fails as any failed call does. The refusal leaves the estimate as it was: it follows responses, the second request's among them. The retry's window is anchored where it starts, as any request's window is, so later requests show the conversation from there, which fit, until the window from there outgrows their budget or its first messages are no longer found, and a fresh one is built (src/gate/reply-judge.ts:524–526, :586–595). A judge the gate has stopped, as when its call timed out, builds and sends nothing more: no preview when it was stopped before it was asked, and neither the retry nor a request after an asynchronous wake provider answers. The log that says the batch is asked once more names the rule and the agent, and comes once the retry is built and the judge hasn't been stopped meanwhile, so a stop or a failed build doesn't leave it as the last word about the call. It quotes the rule's name as the gate's own lines do (quotedValue, src/gate/reply-judge.ts:647), so a line separator in the name starts no line.

  • Texts (R1-4, R1-7, R1-10).

    • The judge's header and its maxInputTokens doc now say that the budget is met by an estimate, at a ratio that starts at three characters a token and follows what each response reports. A request on text denser than that ratio can pass the budget: code, or Chinese, Japanese or Korean text, before a response has reported on such text or after the ratio has followed sparser text. So can one whose newest message alone is over it, since that message is always shown.
    • The header says how a request refused as too long is asked again.
    • The header says the dry run leaves out what modules' gatherContext and MCPL servers' beforeInference would inject, and ReplyJudgeDeps.preview says it's without its injections.
    • cacheTtl's doc says Bedrock drops it.
    • The docs of onError, JudgeConfig, judgeOnError, UpcomingWake, inferenceDiagnostics' judgeHeldEvents and the gate:judge-verdict trace say what a refused call does. upcomingWakes' doc and JudgeRequest.upcomingWakes' say an entry will or may wake the agent, certain: false marking one that only may, and name the batch they leave out.
    • For an agent with the judge wired, wake_remove_rule's description now says in its first sentence that a judge rule's pending batches still go where the rule as it was sends them. 0.28.0 to 0.34.0 say in the last sentence that such a batch still goes to the judge, by the rule as it was, which this change's delivery without the judge under a "wake" refusal would make untrue. feat(toolsets)!: the host's own toolsets: a built-in "settings" toolset and MCPL server tabs #860, merged into main after 0.28.0, added a settings toolset, and src/host-presentation.ts:318 lists that toolset's tools with toolLine, which shows the firstSentence of each description (src/mcpl/tool-signature.ts:163-164), so the last sentence wasn't in that list.
    • The review rejected a finding that a wait drops events never placed in the agent's context, since the workspace module stores its message before the gate runs. 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 (apart from a coalesced push its server sends deferred, which waits outside the context until the server renders it for a turn) and as the framework's own modules store the message an event of theirs carries, and that a wait, like defer, adds nothing more. The judge's own prompt and, for an agent with the judge wired, wake_add_rule's judge parameter said the events stay in context too. The parameter now says that a wait adds nothing more and that what was stored as it arrived stays stored for the next wake. The prompt now says that what was stored as it arrived stays stored for when something else wakes the agent. Neither says the agent will see it then, since its context's window can leave a stored message out.
  • 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.34.0, the latest release:

    • .breaking: the 'refused' value and count, for code that handles each value of the verdict or builds the counts itself, with what's unchanged: what the other verdicts mean, and every sibling package's version range;
    • .added: EventGate.judgeRefusalsMayHaveLifted(), with what a host that constructs its own gate gives it (a judge that rejects a refused call with an error whose providerAdmission is 'deferred', which JudgeFn's doc says too), ReplyJudgeDeps.refusal, ReplyJudgeDeps.failedLastTurn with FailedLastTurn, and UpcomingWake.when;
    • .changed: R1-1 to R1-4, what an UpcomingWake without at now means and that healthSnapshot() leaves out one whose time is given in words, wake_remove_rule's first sentence and wake_add_rule's judge parameter, with the prompt-cache misses their change and the judge's changed prompt make;
    • .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.34.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

  • The refused verdict, 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 host's judge model or provider 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 .breaking fragment says.
  • Holding under "skip", with the refusal and a timer of its own. 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. A refusal that states no time left has no end to time, so it waits for the framework to say the hold ended, as one with no end waits for a release.
  • Asking before the context is read. Each ask runs previewActivation, a synchronous compile of the agent's whole context, so asking the framework first saves that compile for every call it would refuse. The check is the membrane's own (judgeRefusalFor), so the two can't disagree, and the membrane keeps it for a hold that begins while the context is read. With a refused call costing no compile, the gate needn't keep a refusal of its own to spare asking, so each batch is asked.
  • A refusal with no end held until a release, with a new batch still asked. Holding through turns' ends avoids an ask per turn for each held batch its rule adds nothing to. Letting the rule's next batch ask, once no turn or quiesce holds it, means a hold that lifted unannounced ends at that batch, without polling.
  • judgeRefusalsMayHaveLifted(), called where an operator releases a provider wait (always, since the wait may have lifted unannounced), where a surgery lets go of the store, where an acceleration hold ends while the gate runs, where a turn's wait for compression ends, and where an agent ends, since the judge may then read another. Those holds state no end, or an end the release comes before, or, for an acceleration hold past its end whose timer hasn't run, no time left. One its host extends at its end refuses again with the new end, and its release comes later, with the event. A provider wait with an end lapses by time alone, with no event, so its refusal states the time left from the clock reading that found it live, which is always some. One with no end states none, and its refusal waits as one with no end does.
  • Each refusal on its own batch. A refusal binds the batch whose call got it, and the rule's next batch takes that batch in. Two rules can hold different refusals, with different ends, so a verdict on one rule's batch says nothing of another's hold, and gate_status names each rule's, in the rules' order. What it costs is a timer for each rule with a held batch.
  • The throttle (refusedBefore). Since each batch is asked, a held batch can be refused again at its rule's next batch and at each stated end while one hold lasts. Logging and tracing only its first refusal keeps one line for each hold, and the count still takes every refusal. A release, or a turn that stops a call that took the batch in, ends that run, so the next refusal, which may be another hold's, is logged and traced with its own words. A release ends it for a call that's out when it comes, too, and a refusal that call reports holds nothing: the hold it names may be the one released.
  • wake_remove_rule's first sentence for an agent with the judge (src/framework.ts:14707–14709). The settings toolset shows a tool by its first sentence, so that sentence says a judge rule's pending batches still go where the rule as it was sends them: to the judge, or, when its call is refused under onError: "wake", to the agent without it. After it comes WAKE_REMOVE_RULE_REST (:14473), the rest of the description every agent gets (:14653), so the two descriptions can't drift apart there.
  • The failed-turn line, its five endings, and failedLastTurnFor. Measured on claude-haiku-5-5 with the line as this change words it, a request whose list holds only a queued wake drew WAIT 10 times of 10, and one whose list holds only a heartbeat six hours off drew REPLY 10 times of 10, as did one with an empty list (Not verified says what was measured). Without the line, requests with nothing listed drew WAIT 10 times of 10. The ending is chosen by the kinds of wake in the list rather than by a reading of the framework's own, so it can't disagree with the list, and no second account of what's on its way needs keeping. A certain queued or held wake gives its ending first, since it answers what the line asks. A queued or held wake that only may come gives an ending that says so, and what a drop would leave, since main's scheduler drops such a wake once the agent has answered the rows it reads, so the plain ending would read as a wake on its way, and the last would say nothing runs it again, where neither is known (Not verified gives what the judge answered to each). The held ending takes its time from the first certain held wake in the list's order, and gives a time only when that wake has one, since a host's own held wake with none can be let go on a condition of its own, which the ending can't name. It gives no line once a turn has started since the failure, since the turn the line would call the last isn't, even one stopped before it answered. The framework marks the agent, with the time, when its turn fails, at an inference whose retries are spent or at a throw before the stream, and clears the mark when a turn starts, rather than comparing the two times, since a turn can start in the millisecond the last one failed, and fail in the millisecond it started. The line reads its time from the mark, not from the inference failure counts, which a throw before the stream doesn't touch.
  • UpcomingWake.when. Without it, a wake due at no nameable time would read "as soon as it is free". A batch held for the judge under a refusal with no stated end has the when "with its rule's next batch, or once the hold on its calls is released, and no turn or quiesce holds it" (src/gate/event-gate.ts:4500). upcomingWakes sorts the wakes as the judge's own list does, soonest first and those with a when last, so a host's judge gets them in that order too.
  • A collecting batch, in the list. It's asked when it fires, whatever refusals stand, so it's listed at its due time as going to the judge, with certain: false, as any judge rule's batch is, and as joining its rule's held batch when there is one. With no judge wired, a judge rule's batch is delivered as a debounce's is, and listed so.
  • Installing before firing, and reading the rule when asked. Both are needed for gate.json edits: the first for reload's order, the second because a fired batch carried the rule it was batched under. With the new rules in force first, a reload whose loop of sends stops at a throw leaves the batches after that send without timers. The next check reads gate.json again, since its text is recorded only after the sends (src/gate/event-gate.ts:3107–3110), but finds its rules already in force, so it clears and sends nothing (:3104). 0.34.0 sent the batches before it put the new rules in force, so at such a throw it kept the old rules, and its next check sent the rest. Such a throw takes a host that gives the gate its own emitTrace, changesJudgedContext, getAgentNames or clock, and that throws. The framework passes no clock, its trace emitter catches each listener's throw, and its changesJudgedContext and getAgentNames only read (Not verified).
  • What each list leaves out (upcomingWakesBut). The judge's request leaves out only the batch it asks about. upcomingWakes leaves 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, upcomingWakes lists that rule's batch being collected, decided or held, or its buffered events.
  • The buffered entry, and each condition as a 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, through the delivery a fired batch takes, so the entry is certain where a fired debounced batch's is, by the one test both entries call (surelyWakes, src/gate/event-gate.ts:420): it only may where each event delivery gives an agent has a message of its own written for it, since main's scheduler drops such a wake once the agent has answered them (fix(gate)!: a debounced batch's wake starts no turn once the agent has answered every event in it #972). 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. That condition, and the one for a batch held for the judge during a turn or the host's quiesce, are each a when, since a wake with neither at nor when reads as due as soon as the agent is free. So is a batch the judge is deciding, "once the judge decides it", since it waits on a verdict.
  • The recipient test on each entry this change adds to the gate's list. healthSnapshot() asks the gate about every agent, so without it an agent the gate doesn't deliver to would be shown judge rules' batches, held batches and buffered events that never wake it. main tests a rule's batch in its debounce window the same way.
  • kind on each entry. main gives every UpcomingWake a kind, which health reports without the entry's words, since those can quote a reminder or a queue reason. Each entry this change adds takes the kind of what it is, so a batch delivered without the judge reads as a debounce's, and one that goes to the judge, or may, as a judge rule's.
  • Health leaving out a wake with a when. Health's entries give a time and no words, so a wake due at no time anyone can name would read as due as soon as the agent is free, and would sort first, ahead of timed wakes it can push out of the eight shown (src/framework.ts:6941–6945).
  • The refusal's log line says where a refused batch goes: held while the judge's calls are refused, under "skip", or to delivery without the judge, under "wake". It also counts the events queued behind the call, which go where the batch goes, so a batch of one event with more behind it isn't logged as moving one event. It names the rule's oldest events the cap dropped as it held the batch and its queue (src/gate/event-gate.ts:4621), so a batch of 100 with 100 more queued isn't logged as all held.
  • The failure line under "skip" says a batch whose judge call fails is dropped without waking the agent (src/gate/event-gate.ts:4585), where 0.34.0 said it doesn't wake the agent. A batch can now also be held under "skip", and a batch whose call fails may have taken in its rule's batch held for a refusal, whose events go with it.
  • The 300-character cut of a refusal's hold. It bounds the hold's words wherever they appear, as a failed call's reason is bounded, so a long provider reason can't swell the log line or the trace. What it costs is the end of a longer hold's words.
  • Marking a refusal's hold as supplied text (suppliedValue, src/gate/event-gate.ts:4612). A provider's reason is the provider's words, not the framework's. Kept to one line, with a notice's opening escaped, it can't end the log line or open a line that reads as the framework's own, there or in the trace. What it costs is a backslash before such an opening, and a provider's line breaks read as spaces.
  • buildRequest's clock read after its readings. The failed turn and the host's own wakes (ReplyJudgeDeps.upcomingWakes) are read after the preview (src/gate/reply-judge.ts:471–487), and the question tells every time it shows, those in the gate's list the request carries included, against the clock read after them (:494). Read before the preview, as 0.34.0 read it, a hold's time would be overstated by however long the preview took. What it costs is that a host calling buildRequest without now gets times as of the build, which the changelog says.
  • The lists, asking held batches in turn, and the overflow line. Without the second, batches flushed together never see each other. The flush checks each held batch's refusal when it runs, so a batch whose refusal still stands stays held. The cap of eight bounds the request.
  • The quoting helpers and the module. Escaping alone leaves line starts to forge a message, and indenting alone leaves tags. A channel or an author left unquoted could hold " in #admin" or " from u2" and read as the line's other field, so each is quoted as a JSON string. A message is cut before it's quoted, at its quoted width, leaving room for its cut's marker by the marker's own length, which names at most as many characters as the text has: cut after quoting, a cut can split an escape, or put the cut's marker at the start of a line, where it reads as a message's opener, and cut before quoting at its raw length, escapes can swell it past its bound. The head and tail it keeps are taken whole, so no surrogate pair is split either. A block's label opens a line too, and quoting indents every later line alike, so a [ that opens a line of a message's text or of a tool's result takes a backslash before the labels are written. Before the [, it passes over the characters src/supplied-text.ts already skips when it looks for a notice tag (SKIPPED: whitespace other than a line break, control, format and default-ignorable characters), so a zero-width space can't hide the [ from it. A lookalike bracket, such as U+FF3B, isn't escaped, since no list could cover every character a reader might take for a bracket. A participant's name is escaped whole when it fits, and otherwise cut at its end, at its escaped width, so the opener leaves the text room for a cut's marker, as long as the marker that names the text's whole length (src/gate/reply-judge.ts:163, :167–169), and the message keeps its bound. The module is its own, so code outside the judge can take the same rule.
  • An ask taking in its rule's held batch. Without it, a rule's later batch could be asked and delivered while its earlier one waited, and the agent would read the later events first. With it, a rule's held batch and a call being decided never coexist, so a queue or a held batch only ever gains newer events and needs no sorting. The batch it makes is capped as a held batch is, dropping the oldest events past the cap, counted and logged, so taking a long held batch in can drop some of its oldest events, as holding it longer would.
  • The too-long retry, and ask().
    • Without the retry, a request over the model's window fails, and the next is built at the same ratio, since the ratio is corrected only from a response. So while the agent's context stays that long and no response corrects the ratio, every batch's request fails the same way, and under "skip" the agent doesn't wake for its judge rules meanwhile.
    • The refusal carries no count the framework can read. membrane's MembraneError has no field for one, though the provider's message often states it in its own words, such as Anthropic's "prompt is too long: N tokens > M maximum", which this repository would have to parse. So the retry is built at the estimator's own floor, one character a token, which keeps it within the budget the host sets (maxInputTokens) unless its text is denser than that, its newest message alone is over the budget, since that message is always shown, or the budget is under the judge's own text and the 1,000 tokens the conversation is always given. A lowered estimate would be a guess, and the floor as the estimate would cost every later request a smaller window, so the estimate is left to the retry's response, which is a measurement.
    • One retry bounds the cost. A request built at one character a token isn't asked again, since its retry would be built at the same ratio.
    • It builds the retry at one character a token whatever the estimate is when the refusal comes back, so another call's response moving the estimate meanwhile doesn't change it.
    • It builds the retry from a fresh window, whatever anchor is set, since an anchored window that still fits the retry's budget would make the same request again, whether it's the refused request's own or one another call anchored while the retry's preview was read. It drops the agent's anchor first too, so a call after a retry that was stopped, or failed to build, doesn't start from the refused window either.
    • ask() is the one place the judge's call is made, so the first ask and the retry send the same way.
    • A judge the gate has stopped checks its signal as each build begins, before its preview, again after the preview, as 0.34.0 does, and after an asynchronous wake provider answers (src/gate/reply-judge.ts:466, :468, :488), so a call that timed out, or was stopped before it was asked, doesn't compile, or send, a request nothing waits for. The retry's build makes the first of those checks too.
  • The guards.
    • A stated wait that doesn't end at a time a Date can name (Infinity, NaN, or past the year 275760) counts as no end: a timer given a non-finite delay fires at once, and gate_status must be able to name the end.
    • The timer's step: a delay past 2^31−1 ms fires at once too, so a longer wait is waited out in steps.
    • Each line for a batch the gate couldn't deliver to any of its agents names its rule and quotes the error's text (logUnsent, src/gate/event-gate.ts:4209–4215), so a line break in it starts no line. A judge rule's batch whose delivery throws is logged through it (:4727–4731), and so is a debounce's (:4198), where main puts the error's text unquoted (main at 9d57cc8138: src/gate/event-gate.ts:4153, :4576), as the .changed fragment says. A batch that woke some of its agents before delivery threw keeps main's line for that (deliveredInPart, src/gate/event-gate.ts:2245–2250), which this change leaves as it is.
    • A refused call reads the clock once (src/gate/event-gate.ts:4615) and works out its stated end from that read (:4616–4617), which the held batch's refusal keeps while it's ahead (:4619) and the trace gives as until (:4654). Its timer is armed for the time left on the gate's clock (:4666).
    • The catch around failedLastTurn (src/gate/reply-judge.ts:476–481): one that throws gives the judge no failed turn, as a throwing upcomingWakes gives it no wakes.
  • ProviderWaits.active's clock, read after its retry (src/provider-waits.ts:258–261). This change gives active an optional now, and when it's left out, the clock is read after the retry of the recorded waits, as 0.34.0's read was. A default parameter would read it before the retry, and a wait that ended while the retry ran would be reported as live.
  • The refusal's times on the gate's clock. Every other time in the gate's own wake list is on the gate's now, so the refusal's end and the checks against it are too.
  • The mocked clock in event-gate-judge. The file's tests step the gate's own timers (the debounce and a refusal's stated end) on node:test's mock timers (setTimeout, setInterval and Date) 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. A test that bounds a refusal's or a hold's end steps to a millisecond before it and checks nothing was asked yet, then steps the last millisecond, since a timer up to 5 ms early would otherwise pass. The three tests of a provider hold's end then check, without moving the clock on, that the judge is called at that millisecond (test/event-gate-judge.test.ts:2688, :2760, :2779), so a release that comes late fails them. The long-refusal test steps one timer's whole span at once, checks that the next timer is armed for exactly the rest of the wait, then steps to a millisecond before the wait's end and then the last millisecond, since 5 ms steps can't reach a wait of a month. The failed-turn ordering test holds the clock still while a turn starts and fails, so that both can fall in one millisecond: those waits let pending work settle until each condition holds, failing after 5,000 ms of real time, the figure the file's until() takes by default on its mocked clock, with the condition that didn't (test/event-gate-judge.test.ts:2962–2968). Its waits for a turn to end and for the judge's call step the mocked clock, as the file's other tests do (:2957, :2974–2975). 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.
  • Test helpers and test changes. refusingMembrane refuses chosen requests as too long, judgeOver builds a judge over a conversation and budget a test chooses (the fresh-window test needs a conversation whose anchored window still fits the retry's budget), and quietly silences 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. scriptedJudge records when each request was asked, so tests assert on the time of an ask rather than sleep through windows. answerAt and failAt settle one waiting request out of order. makeGate's onTrace lets a test act as a trace listener, and its isHeld gives the host's held channels, for the tests of a held channel's batch in the list and at delivery. logDuring collects what the gate logs while a step runs. refusedOnce (test/event-gate-judge.test.ts:1040) makes a gate whose first batch's judge call was refused, with a stated wait or none, and the two tests of the wake list under a refusal call it in four places. twoRules (:1049) gives them two judge rules, one due soon and one whose debounce a case sets, dueOf (:1055) reads a rule's due time and listed (:1056) the list's entries. Inline, each case would repeat the refusal's setup and those reads. heldFramework (test/event-gate-judge.test.ts:2699) builds a framework whose host hold gives the slices a test lists, one per consultation, with the judge under "skip", and returns what the two tests of a host's hold read (:2747, :2768). Inline, each would repeat the framework's setup and its providerHold hook. answersTaken (test/event-gate-judge.test.ts:69) counts each answer the gate takes, so the five tests of a stopped call's late answer wait until the gate has taken it rather than for a fixed stretch of time. The timed-out call's test has nothing to wait for, since its race has already settled on the timeout and nothing takes the late answer. lastMillisecond (:53) checks that at least a millisecond is left before an end, steps to a millisecond before it and checks what a test gives, then steps the last millisecond, and refusalEnd (:51) reads the stated end of the refusal a rule's held batch has. byThisMillisecond (:60) lets the framework settle without moving the clock on, until what a test gives holds or five seconds of real time pass, and checks it; the three hold-end tests call it. lastMillisecond is in eight tests and refusalEnd in nine, six of them with both. 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. The test of a judge stopped while an asynchronous wake provider answers holds the provider open until the stop, so a check made before the provider answers doesn't pass it. exhausted-turn-rearm's judge test bounds the failed-turn line's time as it bounds the list's. The test that pins the retry to a judge at one character a token runs on a frozen Date, so no event's age changes between its two requests.
  • Cut:
    • the hold's start time in the refusal, which nothing read;
    • the two calls at a provider hold's release;
    • the comments that gave reasons, which are in the commit message;
    • the sorting by arrival an earlier version added where batches meet, which taking in the held batch made unnecessary;
    • a separate Number.isFinite check and a second refusal check before the held flush is queued, which the Date bound and the flush's own check cover;
    • on a rent pass from a least written before reading the change: the ask counter and the ordering of outcomes, the six-way refusal notice, "refusals aside", the wake list's branches for a batch delivered without the judge under a refusal, and the framework's own reading of which wakes are on their way (wakesOnTheirWay, with the parked starts it read, a read-only mode of the scheduler's read check, and ReplyJudgeDeps.wakeReading). Each removes a precision the measured cases don't need, and each error the cut allows is bounded and errs toward waking the agent.
    • on a check of the final state, with the framework's judge asking before it reads the agent's context: the gate's own refusal, with its timer and the rules for which refusal stands, the "wake" delivery without asking while a refusal's stated wait lasts, the end of every held batch's refusal at any verdict, the wake list's branch for a batch due before a refusal's end, the framework's second copy of its membrane's four checks, the failed-turn mark and settle at the five places that end a turn exhausted, with the trace at four of them, which failTurn replaces, and the &#91;/&#93; escape for a bracket in a name, which the backslash escape replaces. The tests of the window go with it: the "wake" delivery without the judge, the ordering of refusals and verdicts across rules, and a rule's batch behind its own deciding call while a refusal lasted, with the deciding helper they used.

Tests

This head is 140af4c73d. The suite ran in CI for it. Each part was taken out at 0f95d9e1e7, which differs from this head in one doc comment alone.

  • The whole suite, in the repository's CI for this head, on Ubuntu with Node 22.12.0: tsc builds clean, npm test runs 10,965 tests, 10,901 passing, 63 skipped, one to-do and none failing, and two stop files and conversation-routing run again without a forced exit, 133 tests, all passing. CI runs them on this head merged into main at 36b6dfe9de, as 3301a6d, whose tree is this head's, since this head already carries that main. The repository has no lockfile, and its package.json asks for chronicle ^0.6.0, context-manager ^0.27.0 and membrane ^0.8.0 || ^0.9.0 || ^0.10.0. The main run doesn't print the versions npm install resolved. The three runs at the dependency floors do, chronicle 0.6.0, context-manager 0.27.0 and mcpl-core 0.4.0, with membrane 0.8.0, 0.9.0 and 0.10.0 in turn, and their counts are the same.
  • Each part taken out in turn. In a clean checkout of 0f95d9e1e7, on Linux with Node 24.22.2, chronicle 0.6.0, context-manager 0.27.0, mcpl-core 0.4.0 and membrane 0.10.2, tsc exits clean, and the built output was changed in one place at a time, 145 places in all, each taking out one behaviour this head keeps: 108 in this change's parts, 14 where its merge with main at c788d078c4 meets them, 14 where its merge with main at 9d57cc8138 gives the framework's words, 3 where its merge with main at e0fa00f666 meets the wait for compression, and 6 where its merge with main at 36b6dfe9de meets fix(gate)!: a debounced batch's wake starts no turn once the agent has answered every event in it #972. After each change, the test files that reach that code were run, and budget-refusal-hold and the three test files fix(gate)!: a debounced batch's wake starts no turn once the agent has answered every event in it #972 added, beside runs of the unchanged output, three over every file the sweep ran, budget-refusal-hold among them, and one more over budget-refusal-hold and the three fix(gate)!: a debounced batch's wake starts no turn once the agent has answered every event in it #972 added, which all passed. 141 of the 145 are caught: 140 fail at least one test, and with a stated end that never passes, event-gate-judge runs into its 300 s limit. With a failed turn left unsettled, budget-refusal-hold runs into it too, after 26 failures. No test catches the other four: the judge's check of its signal after its preview and a silent start's throw leaving the agent's open turn unsettled, which are both 0.34.0's own, a thrown error's shownReason in its trace, which is main's own, and the order that delivers a refused call's batch before the refusal is traced under "wake". By part, with the number of tests that fail for each:
    • The refused verdict, its count, trace, notice and log line. Taken out one at a time: a refusal counted as an error (22), the hold's words left whole and unmarked (1), the trace without the stated end (1), the trace's woke false under "wake" (1), the log line saying every refused batch goes to delivery without the judge (1), gate_status's notice of the refusal (13), the notice without the events held (9), the notice without the stated end (6), the notices in the order the batches were held, not the rules' (1), only the first rule's notice (1), a refusal named past its stated end (1).
      The tests that fail for these and for no other part:
      • in event-gate-judge: "a refusal is counted apart from the failing streak: between or after its failures it neither counts toward the streak nor resets it, and a success does".
    • Asking before the context is read. Taken out one at a time: the judge not asking the framework first (3), the judge asking about another model than its own (2), the framework not giving the judge its answer (1), the membrane's own check at the call left out (1), the too-long retry built without asking the framework first (1).
      The tests that fail for these and for no other part:
      • in reply-judge: "asks the framework whether it would refuse the call, on the judge's model, before reading the agent's context: a refused call reads none and sends nothing".
    • Holding under "skip", with the refusal and a timer of its own. Taken out one at a time: a refused batch not held under "skip" (34), a refused batch held under "wake" too (5), a refused batch held without its refusal (21), no timer at the stated end (9), a timer step before the stated end not arming the next step (1), a timer delay past 2^31−1 ms, uncapped (1), an end past what a Date can name taken as an end (2), a stated end that never passes (event-gate-judge runs into its 300 s limit before any test fails), the held batches' flush not checking each one's refusal when it runs (3), a batch that joins its rule's held batch without asking while the refusal stands (8), a batch fired during a turn joining a refusal-held batch without ending its refusal (1), a batch that takes in its rule's held batch left uncapped (1), that batch taking the held events after its own (7), a no-end refusal's batch not held (8), a no-time-left refusal given an end (1).
      The tests that fail for these and for no other part:
      • in event-gate-judge: "a batch held for a refusal is never judged once the gate is disposed"; "under 'skip', a batch asked while a refusal with no end holds its rule's batch keeps the newest 100 events of the two"; "under 'skip', a turn that ends while a refusal with an end holds leaves the held batch held"; "a refused call under the default onError wakes the agent with the events queued behind it".
    • The throttle. Taken out one at a time: every refusal logged and traced (3), every refusal traced (3), a held batch never marked as refused before (2), the mark not carried into the call that takes the batch in (3), a release leaving a held batch's mark (1), a release leaving the mark on a call that's out (1), a stopped call's mark carried onto the batch it holds again (1), a release not marking the calls that are out (1), a refusal reported by a call out at a release holding its batch (1), that batch not asked again (1), that refusal's mark not carried, so a refusal of the ask again is logged and traced again (1).
    • The failure line under "skip". Taken out one at a time: the line saying the batch doesn't wake the agent, as 0.34.0's did (1).
    • judgeRefusalsMayHaveLifted(). Taken out one at a time: the release doing nothing (13), a release leaving the held batches' refusals (12), the framework's call at a hold's timer release (1), its call at an operator's release (2), its call at a surgery's end (1), its call at an agent's end (1).
      The tests that fail for these and for no other part:
      • in event-gate-judge: "an agent's end tells the gate its rules' held batches may go back to the judge, which may now ask about another agent, whether or not the agent was held".
    • Every pending batch, and the wake list. Taken out one at a time: the judge's request leaving out the asked batch's whole rule (2), no entry for another rule's batch being decided (5), no entry for a held batch (6), no entry for events buffered behind a turn (7), buffered events each written for the agent listed as certain (1), a debounced batch whose events were each written for the agent listed as certain, main's own (5), buffered events on a held channel counted (1), the gate's list not putting a when last (2), a batch held under a refusal with no end listed as waiting only for a turn (1).
      The tests that fail for these and for no other part:
      • in event-gate-judge, 12 tests.
    • The rule in force. Taken out one at a time: a batch judged by the rule it was batched under (3), a rule write that doesn't send the cleared rules' batches (6), a reload that doesn't send the cleared rules' batches (3).
      The tests that fail for these and for no other part:
      • in event-gate-judge: "a batch held for a resident's turn is judged by the rule's instructions as they are when the turn ends"; "a batch whose rule's instructions are edited in gate.json while it waits is judged by the edited instructions"; "a batch whose rule's instructions are rewritten through addPolicy, which wake_add_rule calls, while it waits is judged by the new instructions";
      • in event-gate-strict-rules: "a rule a write removes has its pending debounce batch delivered"; "a write that changes a debounce rule sends its batch as the timer would: held while an agent's turn runs"; "rules that share a name keep their state through a write that leaves them as they were, in order, and lose it where one changes".
    • Delivery's log line. Taken out one at a time: the line for a batch the gate couldn't deliver without its rule's name (5), under "wake", a refused call's refusal traced before its batch is delivered (none fail).
      The tests that fail for these and for no other part:
      • in event-gate-strict-rules: "a batch whose line quoting an event can't be written is named in the log and dropped, and wakes no one".
    • The failed-turn line, its endings and its mark. Taken out one at a time: the line left out (4), a held wake's end left out (2), the framework's line never given (5), the mark not cleared when a turn starts (2), no mark or settle at a throw before the stream (2), failTurn not marking (5), failTurn not settling (37, with budget-refusal-hold stopped at its 300 s limit, so its failures are those read before then), failTurn not tracing (74), a silent start that throws settling the open turn (none fail), the queued ending never given (3), the held ending never given (3), the held ending reading the wakes in the order they were given, not the list's (1), the "nothing runs it again" ending never given (3), an uncertain queued wake taken as queued (1), an uncertain held wake taken as held (1), the queued maybe ending never given (1), the held maybe ending never given (1), the held maybe ending without its time (1), the held maybe ending given ahead of the queued one (1), the judge never reading failedLastTurn (5).
      The tests that fail for these and for no other part:
      • in budget-refusal-hold, 28 tests;
      • in default-error-policy: "a failure the provider says may not be retried, thrown from a stream's iterator, ends the turn as a throw did, without asking the error policy"; "after a non-retryable error event, a throw or a later event decides nothing more: the turn ends as that event alone ends it"; "after an aborted event no stop asked for, a throw or a later event decides nothing more: the turn ends as that event alone ends it"; "an error that is not a MembraneError, thrown from a stream's iterator, ends the turn as a throw did, without asking the error policy";
      • in event-gate-judge: "tells the judge of a failed last turn only while no turn has started since, by the order of starts and failures, not the clock: a turn that failed in the millisecond it started is named, and a later turn started in the millisecond of a failure, then stopped, ends the line";
      • in exhausted-turn-rearm, 60 tests;
      • in inference-failure-bounds: "a MembraneError from another copy of membrane is recognised by its name"; "a ~1 MB plain Error (no classification) is bounded the same way"; "an oversized provider code is bounded in the trace, the marker and failures.log"; "stream error event: a ~1 MB provider error leaves a bounded, classified trail"; "stream setup throws: a ~1 MB provider error leaves a bounded, classified trail"; "stream throws: a ~1 MB provider error leaves a bounded, classified trail"; "the breaker reads the status a real failure carries: a 404 never sheds; a 400, a 413 under either label and no status do";
      • in reply-judge: "gives the failed-turn line in the words the judge's model was measured on, with a queued wake listed, a far-off one, or none"; "shows no failed-turn line when it isn't given a failed turn, and a provider that throws counts as none".
    • ProviderWaits.active's clock. Taken out one at a time: a time the caller gives ignored (1), the clock read before the recorded waits are retried (1).
      The tests that fail for these and for no other part:
      • in provider-waits: "reads a wait as of the time it is given, so a caller that states its time left reads the clock once"; "reads its own clock after it retries the recorded waits, so a wait that ends while the retry runs is over".
    • Participant text in the document. Taken out one at a time: < not escaped (6), later lines not indented (4), a line-opening [ not escaped (1), a name not kept to one line (2), a name's square brackets not escaped (2), a name's backslashes not escaped (2), a participant's name and a wake's words without the entities (2), a called tool's name given the entities twice (1), a returning tool's name given the entities twice (1), U+2028 left as it is in a tool call's input (1), an input JSON can't hold shown as nothing (1), a message cut at its raw width (2), a long name not cut (1), channels and authors unquoted (4), a channel given a backslash escape on top of JSON's (1), an author given a backslash escape on top of JSON's (1), the addressed marker after the channel and author (1), the prompt naming the agent unescaped (1).
      The tests that fail for these and for no other part:
      • in reply-judge: "cuts a long message before quoting it, so the cut splits no entity or character and its marker is quoted like the rest"; "escapes a '[' that opens a line of a text block, of a tool's result or of an event, so none reads as a block's label, and leaves the real labels as they are"; "escapes markup characters in a participant's name, an event's channel and its author, and quotes the channel and the author each as its own value, so an author can't read as a channel"; "shows a message's text and an event's over several lines, indented under the message's name or inside the event's item, escaped, with names, channels and authors each on one line"; "cuts a long message to its bound, keeping all of it but the cut marker's own length"; "keeps a message within its bound whatever its participant's name, cutting a name too long to leave room"; "puts the gate's addressed marker right after the time, ahead of the channel and the author".
    • The judge's list. Taken out one at a time: a when not put last (2), the overflow line without its maybe count (1), a wake's words unquoted (1), a wake's words given the backslash escape on top of the entities (1), a wake's time in words unquoted (2).
    • The too-long retry. Taken out one at a time: no retry (8), the retry at the same ratio (3), a request built at one character a token asked again (1), the retry from the anchored window (1), the retry's log line quoting the rule's name with JSON.stringify (1), the check after the preview, 0.34.0's own (none fail).
      The tests that fail for these and for no other part:
      • in reply-judge: "asks a refused request once more at one character a token and leaves the estimate as it was, and asks one built at one character a token only once"; "is asked once more at one character a token in a fresh window, though the last request's window still fits and the estimate changes while the retry's preview is read"; "is not built or asked again once the gate has stopped the judge, as when its call timed out before the refusal came back"; "logs that the batch is asked once more only once the retry is built and the judge hasn't been stopped meanwhile"; "quotes the rule's name in its log line as the gate's lines do, so a line separator in the name starts no line"; "refused again, fails as any failed call does, after two requests"; "starts the retry's fresh window though another call anchors one while the retry's preview is read".
    • The framework's words, from the merge with main at 9d57cc8138. Taken out one at a time: gate_status's notice quoting the hold (11), the rule's last verdict giving the hold's words as its reason (4), the failure line giving the framework's words, not the error's own (3), the gate's timeout rejecting with a plain error (2), a start that throws outside its stream settling with the error's own message (1), that settle without the error as its cause (1), a start that fails for good before its stream settling with the error's own message (7), that settle without its cause (7), a stream's error settling with the error's own message (8), that settle without its cause (7), an error thrown from the stream settling with its own message (4), that settle without its cause (3), an aborted stream's trace without shownReason (1), a thrown error's trace without shownReason, main's own (none fail).
      The tests that fail for these and for no other part:
      • in code-execution: "a turn that ends on a provider's error tells its scripts what failed in the framework's words, not the provider's"; "a turn that ends on a provider's error thrown from its stream tells its scripts what failed in the framework's words, not the provider's";
      • in ephemeral-settle: "rejects only after error policy exhausts retries";
      • in event-gate-judge: "a failed judge wakes the agent by default (fail open)"; "a judge that fails on a provider's error shows the framework's words in gate_status, and stderr keeps the provider's"; "a judge whose answer held no verdict is shown in the framework's words, and stderr keeps what the answer said"; "a judge that rejects as soon as it's stopped still shows the gate's timeout, in gate_status and on stderr"; "a judge the gate times out shows the gate's own words in gate_status";
      • in present-while-acting: "an ephemeral run whose start throws outside its stream fails with that error, not by its watchdog";
      • in provider-waits, 9 tests;
      • in provider-words: "an ephemeral run that ends on a provider's error (start) rejects in the framework's words, with a copy of the error as its cause"; "context-manager's refusal of a compile is told in context-manager's own words"; "an ephemeral run that ends on a provider's error (stream) rejects in the framework's words, with a copy of the error as its cause"; "an ephemeral run that ends on a safety error rejects saying its request couldn't be completed"; "an ephemeral run that ends on an error that isn't membrane's but carries its fields rejects saying its turn failed"; "an ephemeral run that ends on an error that says nothing of its origin rejects saying its turn failed, with a copy of the error as its cause"; "an ephemeral run that ends on a provider's error (thrown) rejects in the framework's words, with a copy of the error as its cause"; "a resident's turn whose stream aborts on an error is told so in the framework's words";
      • in tool-result-guard: "an ephemeral run whose new start after a budget restart fails settles the batch it kept as it leaves"; "an ephemeral run whose new start after an error retry fails settles the batch it kept as it leaves"; "an ephemeral run that ends by an uncaused error with its batch pending settles the batch as it leaves".
    • The wait for compression, from the merge with main at e0fa00f666. Taken out one at a time: the judge's call made while the agent's turn waits for compression (2), the wait's end not telling the gate (2), the gate told only when the wait is released, not when a stop or a removal ends it (1).
    • The merge with main: the recipient test, kind, health's list and the row check. Taken out one at a time: a rule's batch in its debounce window listed for an agent the gate doesn't deliver to, main's own test (1), a batch the judge is deciding listed for such an agent (1), a held batch listed for such an agent (1), buffered events listed for such an agent (1), a batch going to the judge given the kind of a debounce's (3), a debounced batch given a judge rule's kind, main's own entry (3), a batch the judge is deciding given a debounce's kind (1), a batch held until a refusal's stated end given a debounce's kind (1), a batch held under a refusal with no end given a debounce's kind (1), a batch held for a turn given a debounce's kind (2), buffered events given a judge rule's kind (3), a queued wake given a provider hold's kind, main's own entry (2), health listing a wake with a when (1), the scheduler's row check looking a gate's routed wake's channel up on gate rather than its server, main's own (1).
      The tests that fail for these and for no other part:
      • in event-gate-judge: "gives an agent the gate doesn't wake its own self-wake and targeted sleep, not a batch or an untargeted sleep"; "tells the judge the gate's own timers: a self-wake, other rules' batches, not the batch it judges";
      • in health-upcoming-wakes: "quotes no rule name from a gate wake's description"; "lists a queued wake first, with no time and without its reason"; "leaves out a wake whose time is given in words, so it pushes no timed wake past the eight";
      • in presented-watermarks: "a gate's routed wake whose row was read and answered in its channel starts no turn: its channel is looked up on its server, not on 'gate'".
  • Checks earlier tests make, so they pin what they say:
    • the five tests that say the judge is asked at a refusal's end check that it's asked then, not only that it isn't asked before (test/event-gate-judge.test.ts:1174, :1194, :1221, :1249, :1539);
    • the test of a throwing failedLastTurn checks that it was asked (test/reply-judge.test.ts:747);
    • the tests of a start that throws before its stream, silent and not, check that the failing start's trigger kept suppressProse when it had it (test/exhausted-turn-rearm.test.ts:657), so it's a silent start's failure the silent case reaches;
    • an auxiliary call refused under a clock that moves at each read checks that the wait it states is the one its single reading found (test/provider-waits.test.ts:852);
    • the fresh-window test checks that the first request set an anchor (test/reply-judge.test.ts:100–101), and the retry-log quoting test checks for the whole quoted name (:205);
    • "a wait on an event outside MCPL writes nothing either, as defer doesn't" (test/event-gate-judge.test.ts:392) checks that a wait on a workspace event writes no message and wakes nothing;
    • "under 'skip', a batch asked while a refusal with no end holds its rule's batch keeps the newest 100 events of the two" (:1421) checks the whole of the batch asked, the held batch's last 40 events and the 60 new ones;
    • the failed-turn test turns re-arming off (:2836), so a re-arm's held ending doesn't take the place of its "nothing runs it again" one, and exhausted-turn-rearm's judge test checks the held line with its due time (test/exhausted-turn-rearm.test.ts:701);
    • the test that a failed judge under onError: "skip" doesn't wake the agent waits until the failure is counted before it asserts that nothing woke (test/event-gate-judge.test.ts:457);
    • the test of a batch that took in its rule's held batch, and whose call then fails, checks the failure's log line, with the held event counted in its batch (test/event-gate-judge.test.ts:1458);
    • the test of a stated wait longer than one timer can run checks that the judge is asked right after the wait's last millisecond (:1648);
    • the participant-name escaping test checks how the prompt names the agent (test/reply-judge.test.ts:903), and the '['-escaping test renders a tool name holding \] and one ending in \ (:825–831);
    • the fresh-window test holds the clock (test/reply-judge.test.ts:228), so the age it renders for its event can't move between the two requests whose sizes it compares.

Out of scope

  • R1-4's other two code suggestions, a lower starting ratio and keeping the ratio with the usage state, aren't taken. The review itself puts the cold-start overshoot at cents.
  • R1-8: nothing changes. 0.28.0's notes say each judge request's usage counts as the judged agent's, in its usage tally and the session's totals, as a turn's does.
  • R1-9: nothing changes. The judge stays on unless the host turns it off.
  • R1-3's sentence for the system prompt, saying the document is data rather than instructions, isn't added. Escaping and indenting answer the finding in the document's structure, and a sentence's effect on the model hasn't been measured.
  • A held batch's rule after the rule is rewritten and then removed. A batch held for the judge keeps the rule's judge settings as they were when it was held, or when a later batch joined it. A rewrite of the rule doesn't change them. If the rule is then removed while the batch is held, the batch is judged by those settings (src/gate/event-gate.ts:4384, :4429), though wake_remove_rule says a removed rule's pending batches still go where the rule as it was sends them. 0.28.0 to 0.34.0 hold a batch during a turn and judge it the same way. This change also holds a batch whose call was refused, and a refusal with no stated end can hold one for a long time, so the case can last longer.
  • 0.34.0's per-block cut (cut), which keeps the first and last 2,980 UTF-16 units of a text block over 6,000 and the first and last 280 of a tool's input or result over 600, before the message is cut, slices at those points, so it can split a surrogate pair there. This change leaves it as it is.
  • Re-arming a turn that exhausted its retries came to main with 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, each as one that may be dropped where every request in it reads rows, or is a start merged from wakes that each do.

Not verified

  • The failed-turn line was measured with four of its five endings, on claude-haiku-5-5, through membrane 0.7.2's AnthropicAdapter, with Bun 1.4.4 on Linux. Each answer is read by its last verdict line, as the judge's parseVerdict reads it.
    • This head's words, between 10:53 and 11:20 UTC on November 8, on the first of the saved requests below, the one whose failed turn sent nothing: a failed turn 2m ago, u1's "can you check X?" unanswered, and the new event "lol". Its <if_you_wait> was replaced with the block this head renders for each list, which a test pins word for word (test/reply-judge.test.ts:646–671), 10 calls each:
      • a queued wake listed, with "A wake for scout is already queued.": WAIT 10 times of 10;
      • a heartbeat six hours off listed, with "Nothing runs it again: …": REPLY 10 times of 10;
      • nothing listed, with "Nothing runs it again: …" and that nothing the host knows of is scheduled: REPLY 10 times of 10.
    • The two maybe endings, in this head's words, at 02:40–02:41 UTC on November 10, 10 calls each, on the requests the same harness sent on November 8, with the block this head renders for each list:
      • a queued wake listed as only maybe, with "A wake for scout is queued, but it may be dropped without running a turn; if it is, anything that woke the last turn and hasn't been answered stays unanswered until something else wakes scout.": REPLY 5 times of 10. Each WAIT took the queued wake as one that will run.
      • a turn held by a provider cooldown 25 min off, listed as only maybe, with "A wake for scout is held, due to be let go in 25 min, but it may be dropped …" and the same clause: REPLY 10 times of 10.
      • On November 8 the same two requests, with the clause "if it is, what woke it stays unanswered.", drew REPLY 10 and 9 times of 10. That clause overstated: the wake is dropped only once the rows it reads are answered, and what stays unanswered stays so only until something else wakes the agent. Sent again on November 10, the queued one drew REPLY 10 times of 10, so the fall is the words, not the model.
      • The last ending, "Nothing runs it again: …", carries the same truth and drew REPLY 10 times of 10, above.
    • On requests an earlier version of this change built: the requests were built at c96d6bd884, with membrane 0.8.0 and Node 24.22.1, and sent between 10:33 and 10:35 UTC on October 27.
      • Two judge requests had the line, with its "nothing runs it again" ending: one whose failed turn sent nothing, and one where it answered in part. Each answered REPLY 10 times of 10. The same two requests without the line each answered WAIT 10 times of 10.
      • One answer to the partial request opened with WAIT and ended with REPLY.
      • The ages the requests show were set by hand, to "2m ago" for the failed turn and "10s ago" for the new message.
      • The ending then read "… what woke it stays unanswered until something wakes scout", where this head's reads "… whatever of it is unanswered stays so until something wakes scout".
      • The judge's prompt then read "(they stay in its context, so it will see them then)". Its words now, "(what was stored as it arrived stays stored for then)", weren't measured, here or on November 8, whose calls used the same saved request.
    • On requests feat(gate): judge wake rules — ask a small model before waking #801's code builds, with the line inserted as this change's code then worded it, between 08:08 and 08:10 UTC: REPLY 10 times of 10 on each with the line, and WAIT 10 times of 10 on each without it. Their messages' later lines aren't indented, and their [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.
    • The "held" ending wasn't measured, which the judge reads after an exhausted turn while re-arming is on, as it is by default.
  • How a model reads escaped text (&lt;) in the document wasn't measured.
  • The retry through the framework: no test sends a context_length refusal through the framework's judgeMembraneFor, so whether the retry sees its type there rests on reading the code.
  • A restart after a failed turn: no test or run restarts the framework after one. The failed-turn line reads when the agent's last start failed, kept in memory with the mark that no turn has started since (lastStartFailed, read at src/framework.ts:6916).
  • A too-long refusal that states a wait, by reading. The framework records a wait that any failed judge call states before it passes the error on (src/framework.ts:7129–7130), as 0.34.0 does. So if a provider stated one when it refused a request as too long, the judge's smaller retry would be refused by that wait, at its call's admission (:7126). gate_status would then show the call as refused, and the log line and the trace would quote the wait's words, "no call was made", which is true of the retry but not of the request the provider refused. Under "skip" the batch would be held, and once the wait ends, asked again at the estimate it was first built at, since the estimate follows responses only. We know of no provider that states a wait on a too-long refusal, and no test sends one.
  • Host callbacks that throw, or act, when the gate calls them. The gate calls the host's clock (GateOptions.now), isHeld, changesJudgedContext, getAgentNames and emitTrace without guarding against one that throws, or that changes the gate's state as it's called, as 0.34.0 calls them. The framework passes no clock, its changesJudgedContext, getAgentNames and isHeld only read (src/framework.ts:5715–5718, :5779–5781), and its trace emitter catches each listener's throw (:35492), so only a host that builds its own EventGate with such callbacks meets those states. One of them differs from 0.34.0. A reload whose send throws partway leaves the batches of the rules after it without timers, since the new rules are in force first, and the next check finds those rules in force and sends nothing. A throwing emitTrace stops the send as an ordinary rule's batch is traced as delivered (src/gate/event-gate.ts:4202), and a throwing changesJudgedContext, clock or getAgentNames stops it as a judge rule's batch is sent, before its request is made (:4327, :4345, and the clock as the request's wake list is made, :4463). 0.34.0 sent the batches before it put the new rules in force, so such a throw left the old rules, and its next check sent the rest. Under "wake", a refused call's batch is delivered before the refusal is traced (src/gate/event-gate.ts:4632–4646), so such a trace can't lose that batch, but no test checks that order.
  • A held batch past what its entry names: no test reads the list while a turn or the host's quiesce holds a batch past the time, verdict or release its entry names. However its refusal ends, a held batch goes to the judge through flushJudgeHeldIfClear, which waits while the host is quiesced or a turn that changes what the judge reads is running (src/gate/event-gate.ts:4752-4753). So a host that calls upcomingWakes during such a turn can read a time that has passed.
  • A failed frame whose agent is still bound, after a newer start. The mark promises that no turn has started since, and no test makes a frame fail after a newer start of the same agent. By reading, no route reaches that state. A wake waits while the name holds a turn token (src/framework.ts:23055, :27503–27516), except a context-budget restart, which takes the token over from a frame whose stream that restart first cancels as the framework's own (:19878, :19898). A stream the framework cancelled ends through endFrameworkCancel and returns before any place that marks a failed turn (:30614–30615, :30789–30790, :31087–31088). A running frame otherwise holds its token until its own end, and each place that marks a failed turn does so in the same synchronous run as the turn's settle.

Rupert-2634 added 14 commits October 27, 2026 04:24
…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.
…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.
…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.
… 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.
…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.
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.
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 c96d6bd 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.
…two comments and the doc as the code bears them out

From a review of the previous commits:

- The wait store's revert (4cf7cfb) 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.
…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.
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.

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

5 more findings, less severe
  • Low severity, documentation, src/gate/types.ts:261-264. The onError doc doesn't describe the new refusal behavior. ReplyJudgeOptions.onError still says only that "skip" doesn't wake the agent when the judge fails, times out or answers unreadably. The change gives onError new 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. The JudgeConfig doc 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 uses oneLine or normalizeLineBreaks. 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. wakesOnTheirWay now 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' while providerGates.primaryDepth > 0 and no turn token is held. Both feed the judge's failed-turn line and the wake list. The new failed-turn test only exercises pendingRequests, 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 with when and no at, which 0.28.0 read as 'as soon as free'. Before this change, an UpcomingWake without at meant 'due as soon as the agent is free'. The gate and the framework now also send entries that have when and no at, 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). A JudgeFn that the host passes to EventGate (the judge option) and that renders wakes the way 0.28.0's ifYouWait did will present a hold that only a release ends as 'as soon as it is free'. The at doc on UpcomingWake still says absent means 'due as soon as the agent is free', which now contradicts the new when field, 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.

Comment thread src/gate/event-gate.ts Outdated
* the gate delivers when that turn ends.
*/
upcomingWakes(agentNames: string[], exceptPolicy?: string): UpcomingWake[] {
upcomingWakes(agentNames: string[], asking?: JudgingState): UpcomingWake[] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/gate/event-gate.ts Outdated
Comment on lines +4008 to +4037
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(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 1161 to 1163
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' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Rupert-2634 added 5 commits October 27, 2026 15:17
…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.
… 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.
…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.
…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.
…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.
@nissa-seru
nissa-seru force-pushed the fix/reply-judge-refused-calls branch from b7ca786 to 92b0d3c Compare October 27, 2026 21:08
Rupert-2634 added 4 commits October 27, 2026 19:44
…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.
Rupert-2634 added 2 commits October 29, 2026 11:28
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.
The wake list's entry for a batch the judge is deciding said its queued
events go once the judge decides it. Unless the call wakes the agent, as a
REPLY does, they go to the judge in a call of their own, or are held with the
batch for it, so the entry now says so.

The judge's header said the anchor moves only when the window outgrows its
budget. A request also starts afresh when the anchor's first messages are no
longer found, and the sentence now names that too.

`byThisMillisecond` let the framework settle at most ten times. The judge's
preview reads a store on disk, so a read slower than that could fail a release
that came at the right millisecond. It now settles until what the test waits
for holds or five seconds of real time pass, still without moving the mocked
clock.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

This push (b671ed3710..c5a463ff42) answers the automatic review of b671ed3710. It fixes 2, 3 and 9, and leaves 1 and 4 to 8, each for the reason below.

Fixed:

  • 2: byThisMillisecond now settles until what it waits for holds, or five seconds of real time pass, still without moving the mocked clock (test/event-gate-judge.test.ts:53–60). The mock covers setTimeout, setInterval and Date, so performance.now() is real. With the judge's decide made to wait 60 more event-loop turns, the three hold-end tests pass with this helper. With the one before it, two of them fail at their check that the judge is called at the end's millisecond, and the third fails earlier in that run, waiting for its batch to be held.
  • 3: the entry for a batch the judge is deciding now says what happens to what's queued behind it: "…, which the judge is deciding, with N queued behind it, sent to the judge unless the call wakes the agent" (src/gate/event-gate.ts:4423–4424). On a WAIT, or a failed call under "skip", the queued events go to the judge in a call of their own (:4543–4544), and a refused call under "skip" holds them with the batch for the judge (:4562). A REPLY, or a failed or refused call under "wake", delivers them with it. That a REPLY's delivery can wait behind a turn is true of every batch the list gives, and the list's buffered entry covers it.
  • 9: the header now says the anchor moves when the window outgrows its budget, "or its first messages are no longer found" (src/gate/reply-judge.ts:34–37). That's the same fresh window either way (:573–582).

Left:

  • 1: withdrawing a deferred batch unrecords its wakes from the pending, held, continuation and pass requests, but not from a start parked behind an auxiliary call (src/framework.ts:19404–19407). That list is 0.32.0's (0.32.0's :19021–19024), and so is the parked start's requeue when it resumes. So in 0.32.0 too, a withdrawn wake comes back when such a start is requeued. It's outside what this change fixes.
  • 4: the entry for a batch held under a refusal with no end is a maybe, and a rule keeps its newest 100 events in every hold, as the onError doc says (src/gate/types.ts:370–371). The entry says when the batch can go, not that every event in it reaches a call.
  • 5: a parked start that resumes with no hold, quiesce or turn token in its way starts directly. That's 0.32.0's resume (src/framework.ts:23680), and this change lists such a start, whose request has nothing left to read, as a maybe for that reason (:6262).
  • 6: it needs a scheduler pass to straddle a provider hold's end while the hold's timer runs late. That's the window the description's Not verified section names for work parked beside a late hold, and our earlier answers leave it the same way.
  • 7: the wake list gives debounces and self-wakes as wall-clock times, as 0.32.0 does, and a clock that steps back is outside what either the list or its timers model.
  • 8: the instructions tag's sanitized name is 0.32.0's (0.32.0's src/gate/reply-judge.ts:277), and it's a tag's name, not a participant's label. A label is cut only for an agent name near 12,000 characters (MAX_MESSAGE_CHARS, :76).

Rupert-2634 made these changes, and Ada-1017 made their design calls. Esther-2584 read them word for word, and found two wordings in 3's first form that the final one fixes. Rupert-2634 wrote this reply.

— Rupert-2634

Rupert-2634 added 2 commits November 7, 2026 04:42
Four files conflicted:

- src/gate/index.ts, src/index.ts: this branch exports FailedLastTurn and
  main exports UpcomingWakeKind at the same place; both are kept.
- src/gate/event-gate.ts:
  - the two log lines for a batch whose delivery throws: this branch sends
    both through logUnsent, and main ends each with deliveredInPart(err),
    which says whom a delivery that threw partway woke. logUnsent takes
    main's deliveredInPart text, and keeps this branch's quoted error text
    for a batch delivered to none of its agents. deliveredInPart is main's.
  - upcomingWakes' doc keeps this branch's, with main's sentence on whom a
    batch reaches.
  - the wake list's loop over pending batches: this branch's branches,
    each with main's recipient test and a kind.
- src/framework.ts:
  - the scheduler's filter for wakes already read: main judges each wake by
    its readsRow through rowDone, and this branch had moved the filter into
    alreadyRead, which the judge's reading of the wakes on their way shares.
    alreadyRead reads readsRow through rowDone, with main's channel server.
    rowDone takes readOnly, which now only keeps its log line quiet, since
    main settles no landing there.
  - upcomingWakesFor: this branch's entries, each with a kind.

Where main's changes meet this branch's outside the conflicts:
- main lists a batch, and a sleep naming no agent, only for an agent among
  the gate's recipients. This branch's other batch entries (a batch the
  judge is deciding, one held for the judge, events buffered) take the same
  test.
- main gives each UpcomingWake a kind. This branch's entries take the kind
  of what each says happens: 'debounce' for a batch delivered without the
  judge (none wired, or while its calls are refused) and for events
  buffered for delivery; 'judge-batch' for one that goes to the judge, is
  held for it or is being decided; 'queued' for a wake already queued and a
  start that may start no turn; 'provider-hold' for one a provider hold
  keeps.
- main's healthSnapshot lists upcoming wakes as { at?, kind, certain },
  soonest first by at, at most eight, where an absent at is due as soon as
  the agent is free. It now leaves out a wake whose time is given in words
  (when), which would otherwise sort first and could push timed wakes past
  the eight. The changelog and upcomingWakesHealth's doc say so.
- This branch's changelog lines on the log line for an undelivered batch
  now say they're about a batch delivered to none of its agents, since
  main's line for one delivered in part says whom it woke.
- Tests: the test of the judge's reading reads a wake for a row whose read
  throws, in place of a deferred write, and no longer checks that no
  landing is settled; fixtures name readsRow and stub rowDone; the wake
  list's tests check each entry's kind where they check the entry. Two
  tests are new: an agent the gate doesn't wake is listed no batch held for
  the judge, buffered or being decided, and health leaves out a wake whose
  time is given in words, which pushes no timed wake past the eight.
0.33.0 was released after the merge of main (c788d07) into this
branch, so these fragments now go into the release after it. They
compared with 0.32.0 in eleven places ("In 0.32.0 it was ...", "where
0.32.0 ...", "still didn't in 0.32.0"); each now names 0.33.0, whose
code is c788d07's (c788d07..v0.33.0 changes only CHANGELOG.md,
changelog.d/ and package.json's version). Each sentence was read
against c788d07 and holds there as it held for 0.32.0:

- the refused call's log-and-return branch (event-gate.ts:4437-4446):
  counted and traced nowhere, its batch let go under "skip";
- the failed turn: no failed-turn line in reply-judge.ts (unchanged
  since v0.32.0), the re-arm wake "a turn whose retries ran out, sent
  again (N of M)" (framework.ts:6736);
- UpcomingWake's at doc and the judge's "as soon as it is free"
  (types.ts:390, reply-judge.ts:176);
- the too-long refusal and buildRequest's clock read (reply-judge.ts
  unchanged since v0.32.0);
- wake_remove_rule's last sentence and the judge parameter's "the
  events stay in your context" (framework.ts:13973-13977 and
  JUDGE_PARAMETER);
- upcomingWakes listing self-wakes, a sleep's end and debounce timers'
  batches, a judge rule's as a maybe whatever the gate's judge
  (event-gate.ts:4370-4392);
- the reload's send of pending batches by the old rule.

The breaking fragment's Unchanged line also named the sibling ranges
this change requires as 0.32.0's (^0.5.1, ^0.24.0, ^0.8.0), which the
merge made false: package.json has main's ^0.6.0 and ^0.25.0. The list
only repeated a release's floors, so it's cut, leaving "This change
raises no sibling package's version range", which holds (the merge
changes neither package.json nor package-lock.json against main).

Changelog fragments only; no source or test changes.
@nissa-seru
nissa-seru requested review from slimepriestess and removed request for slimepriestess November 8, 2026 09:29
Rupert-2634 added 3 commits November 8, 2026 07:19
…maller

A rent pass weighed each part of this change against what goes wrong
without it. Two parts carried machinery that costs more than the cases it
serves, and this rebuilds both smaller.

Refusals. The gate keeps one refusal: any refused call sets it, and its
stated end, any REPLY or WAIT, or providerCallsReleased() ends it. The
ask counter and the ordering of outcomes go, so a refusal reported late
holds a batch as one reported on time would. The judge is still not
asked while a refusal's stated wait lasts, since each ask compiles the
agent's whole context. Under onError "wake" the wake list gives a batch
due in that wait as going to the judge, with certain false, though it is
delivered without the judge. gate_status names a standing refusal on one
line, with its stated end and, under "skip", the events held for the
judge, and the failing-streak notice no longer adds "refusals aside".

The failed-turn line. Its ending is chosen by the kinds of wake in the
list the judge is given: a certain queued wake, else a certain provider
hold with its time, else "Nothing runs it again". The framework's wake
list is main's upcomingWakesFor again: wakesOnTheirWay, the parked
starts it read and the read-only mode of the scheduler's read check go.
ReplyJudgeDeps.wakeReading goes. ReplyJudgeDeps.failedLastTurn gives the
time of the agent's failed last turn, and FailedLastTurn is { at }.

Tests of removed parts go, tests of narrowed rules are turned round, and
the failed-turn line is pinned by the kinds of wake and in the words the
judge's model was measured on. The changelog fragments say the same.
…at a verdict in flight ends it

The changelog's "skip" bullet names the next refusal among what ends a
standing one, since any refused call sets it. ReplyJudgeOptions.onError
says that a REPLY or WAIT on a call already in flight ends the refusal,
and the held batches then go back to the judge once no turn or quiesce
holds them. The "wake" bullet names the batch the wake list gives as
going to the judge: a judge rule's batch due before the stated wait ends.
…batch is held

With the newest-outcome ordering gone, every refused call sets the
refusal before its log line is worded, so under "skip" the batch is
always held while the judge's calls are refused, and the line's third
outcome, that the batch goes back to the judge, had no case. The line
now says "is held while the judge's calls are refused" under "skip"
and "goes to delivery without the judge" under "wake", and the
changelog's word on the line says the same.
@nissa-seru
nissa-seru removed the request for review from antra-tess November 8, 2026 13:13
@nissa-seru

Copy link
Copy Markdown
Contributor Author

/review

@anima-review

anima-review Bot commented Nov 8, 2026

Copy link
Copy Markdown

Automatic review of a51ff97484b7.

About these findings

The findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits.

Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase.

Consider explicitly mentioning which findings you accept/decline (by code-formatted numerical id) and why; this enables future rounds of automated review to take your notes into account.

Findings, most severe first:

  • 1. should fix at src/gate/event-gate.ts:4429, src/gate/event-gate.ts:4456, src/gate/event-gate.ts:4462, src/gate/event-gate.ts:4470, src/gate/event-gate.ts:4150, src/gate/types.ts:466, changelog.d/reply-judge-review.fixed.md:3, src/gate/event-gate.ts:4428, src/gate/event-gate.ts:4147: Wake-list deciding, held and buffered entries check only that an agent is a gate recipient, not that their events are addressed to it.
    • Failing case: With gate recipients Alice and Bob, buffer an event whose recipients are only Alice, then ask upcomingWakes(['Bob']): it reports a certain buffered wake for Bob, although deliverEvents excludes Bob and will not wake him; the same false positive occurs for deciding and held judge batches.
  • 2. should fix at changelog.d/reply-judge-review.breaking.md:3: The breaking fragment omits the minimum sibling versions CONTRIBUTING requires.
    • Failing case: A host raising its framework range cannot determine from the breaking entry that chronicle ^0.6.0, context-manager ^0.25.0 and membrane ^0.8.0 are required, despite CONTRIBUTING.md requiring minimum sibling versions in breaking entries.
    • Raised before as finding 1 of the review of 0342d90c7552 and answered: «**2 (should fix):** the .breaking note now names the minimum sibling versions it requires, as CONTRIBUTING asks». At this head, that doesn't hold: The answer said the .breaking note now names the minimum sibling versions (chronicle, context-manager, membrane); at this head the fragment (line 3) names none.
  • 3. minor at src/gate/event-gate.ts:4584, src/gate/event-gate.ts:4620, src/gate/event-gate.ts:4646, src/gate/event-gate.ts:4660, src/gate/event-gate.ts:4338, src/framework.ts:7623: Releases aren't ordered against in-flight calls, so a refusal reported after its hold's release reinstalls an obsolete no-end refusal.
    • Failing case: Under skip, a judge call is refused by a hold with no stated end, but its rejection is still propagating when the hold ends and providerCallsReleased() runs; refusedJudging then installs the old refusal and holds the batch without a timer, and there need be no further release or new event to send it.
  • 4. minor at src/gate/event-gate.ts:4520, src/gate/event-gate.ts:4539, src/gate/event-gate.ts:5238, src/gate/event-gate.ts:5241, src/gate/event-gate.ts:4540, test/event-gate-judge.test.ts:1468, test/event-gate-judge.test.ts:1470: The failing-judge notice says the judge failed its 'last N requests' even though refusals neither add to nor reset the streak, and a test requires that wording.
    • Failing case: After three judge failures followed by a refused call, gate_status says the judge failed its last three requests, although its latest request was refused rather than failed.
    • Raised before as finding 11 of the review of 3a9e9bf537d6 and answered: «a refusal neither counts toward the failure streak nor resets it, so gate_status's "the reply judge has failed its last N requests" was false once a refusal fell inside the streak.» At this head, that doesn't hold: The answer said the gate now marks a refusal inside the streak and the line says "refusals aside"; at :5241 there is no such mark or qualifier, since the description lists "refusals aside" among the cuts.
  • 5. minor at test/event-gate-judge.test.ts:73, test/event-gate-judge.test.ts:82, test/event-gate-judge.test.ts:2385, test/event-gate-judge.test.ts:602, test/event-gate-judge.test.ts:620, test/event-gate-judge.test.ts:1483, test/event-gate-judge.test.ts:1530, test/event-gate-judge.test.ts:1998, test/event-gate-judge.test.ts:20, test/event-gate-judge.test.ts:38, test/event-gate-judge.test.ts:2555: Test helpers spend their bounds in mocked time and use fixed event-loop turns in place of waiting for async work, refusals and verdicts.
    • Failing case: Under a loaded CI worker, a preview or store operation can still be pending while until repeatedly advances Date by 5 ms; the judge-call wait can exhaust 5,000 mocked milliseconds, or the 4,321 ms judge timer can fire, despite the real operation eventually completing correctly.
    • Raised before as finding 2 of the review of b671ed37109b, fixed then; this is a case the fix leaves.
  • 6. minor at src/gate/event-gate.ts:4545, src/gate/event-gate.ts:4584, src/gate/event-gate.ts:4301, src/gate/event-gate.ts:4620: Outcomes of calls already in flight can clear or replace a newer timed refusal, cancelling its timer.
    • Failing case: Start calls A and B, let B be refused with a future end, then let A answer WAIT or REPLY before that end; A clears B's refusal and timer, so a subsequent batch is asked during B's stated wait under either error policy instead of being held under skip or delivered unjudged under wake. A late no-end refusal from A can likewise replace B's timed refusal and cancel its timer.
  • 7. minor at src/gate/types.ts:443, src/gate/reply-judge.ts:265, src/gate/reply-judge.ts:281, src/gate/reply-judge.ts:104: failedTurnLine ignores a host-supplied provider-hold wake's when and substitutes the hold's release.
    • Failing case: A host supplies a certain provider-hold wake with when 'after the hold is released and the retry is approved': the wake list states that condition, but the failed-turn opening tells the judge the wake is let go when the hold is released, potentially encouraging WAIT before the retry has been approved.
  • 8. minor at src/gate/reply-judge.ts:265, src/gate/reply-judge.ts:280, src/gate/reply-judge.ts:462: failedTurnLine takes the first certain provider-hold in producer order rather than the soonest.
    • Failing case: If a host supplies a certain held wake due in two hours followed by one due in one minute, the list puts the one-minute wake first but the failed-turn line says a wake is due to be let go in two hours.
  • 9. minor at src/gate/reply-judge.ts:159, src/gate/reply-judge.ts:167, src/gate/reply-judge.ts:170, src/gate/reply-judge.ts:172, src/gate/reply-judge.ts:488: Each build renders the whole preview history with per-character quoting transforms, so synchronous CPU grows with history even when only the newest window is sent.
    • Failing case: A long-lived agent with thousands of long messages pays thousands of per-character regex/normalization loops on each judge call and again on a too-long retry, potentially blocking the event loop and delaying the gate's timeout; measure build latency and event-loop delay against representative long previews to choose an appropriate bound or cheaper width calculation.
  • 10. minor at src/framework.ts:7578, src/provider-waits.ts:258, src/gate/event-gate.ts:4581: A provider-wait refusal reads the clock before retrying recorded waits, and the gate adds that stale remaining time to a later clock reading.
    • Failing case: If a recorded wait ends at T and active() spends two seconds retrying the journal after the framework reads the clock, the refusal still reports the time left before that retry; the gate schedules its held batch two seconds after T, even though the wait permits calls at T, and can even refuse a wait that expired during the retry.
    • Raised before as finding 12 of the review of 38d4ef0ac128, fixed then; this is a case the fix leaves.
  • 11. minor at src/gate/types.ts:375, changelog.d/reply-judge-review.changed.md:3, src/gate/event-gate.ts:4584, src/gate/event-gate.ts:4626: The onError comment's release conditions for a no-end refusal omit that a later timed refusal can replace it and its timer release the batches.
    • Failing case: Under skip, rule A is held after a no-end refusal; a later batch of rule B is refused with a ten-second wait. That refusal replaces the first, and the timer asks A again after ten seconds even though there was no REPLY, WAIT, or release notification. A host relying on the documented release conditions would predict that A remains held.
  • 12. minor at test/event-gate-judge.test.ts:1368, test/event-gate-judge.test.ts:1371: The late-refusal test checks for an early re-ask only at 1,900 ms of a 2,000 ms hold.
    • Failing case: If the held batch is re-asked 50 ms before the refusal ends, the first assertion still sees two requests and the later polling and content assertions pass, although the batch did not remain held until the stated end.
  • 13. minor at src/gate/reply-judge.ts:269, change-text.md:87: The failed-turn sentence 'Nothing runs it again' overclaims by leaving out starts parked behind auxiliary calls or assembling deferred pushes.
    • Failing case: When a failed agent already has a start parked behind an auxiliary call, the judge is told that nothing will run the turn again and may choose REPLY, although the parked start can proceed and cause an additional turn; the description itself identifies this path.
    • Raised before as finding 12 of the review of b5e853c014c3 and answered: «a start waiting for its deferred pushes to be assembled holds the turn token, but the failed-turn mark clears only once it starts inference (src/framework.ts:24499). The wake list now counts that start as on its way (:6246).» At this head, that doesn't hold: The answer said the wake list counts a start assembling its deferred pushes as on its way (:6246); at this head upcomingWakesFor (:6725-6744) reads no parked or assembling start, since the rent pass cut wakesOnTheirWay.
  • 14. minor at src/gate/event-gate.ts:1115, change-text.md:3: The parser comment says no deployed config has a judge, though judges were released in 0.28.0.
    • Failing case: A maintainer deciding how safely to change parsing of an invalid judge rule may rely on the comment's assertion that no deployed configuration contains one, despite deployments having been able to use judge rules since 0.28.0.
  • 15. minor at test/event-gate-judge.test.ts:844, test/event-gate-judge.test.ts:846: A test title omits batches whose own debounce is due after the refusal's end.
    • Failing case: The test itself creates a rule with a 120-second debounce during a 60-second refusal; with no turn or quiesce, that batch is sent to the judge after the refusal expires, contrary to the title a maintainer might take as evidence.
  • 16. minor at change-text.md:157, test/event-gate-judge.test.ts:2755, test/event-gate-judge.test.ts:73: The description says the failed-turn ordering test uses a five-second real-time bound on a frozen clock, but several waits use mocked-time until.
    • Failing case: If a turn never settles, ended waits up to 60,000 mocked milliseconds rather than five seconds of real time; a maintainer diagnosing that test cannot rely on the stated deadline or on the clock remaining frozen throughout.
Raised before, still answered (23)

…certain hold and claims no release

The held ending took the first certain provider hold in the order the
wakes were produced, while the list shows them soonest first, so with
two holds the line could name a later one than the list's first. It
now reads the wakes in the list's order (soonestFirst, which ifYouWait
sorts by too). With no time, it said the wake is let go when the hold
on it is released, which a host's own held wake may not be: it now says
only that the wake is held, and the list gives its `when`.

ReplyJudgeOptions.onError says that the next refusal takes a no-end
refusal's place. The breaking fragment says this change requires no
sibling version beyond those 0.33.0 requires. The late-refusal test
bounds the refusal's end at its last millisecond, as the file's other
end tests do, and the "wake" listing test's title says that a batch due
after the wait's end goes to the judge.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

This push (a51ff97484..e3b21fc0a3) answers the automatic review of a51ff97484. It fixes 2, 7, 8, 11, 12 and 15, corrects the description for 16, and leaves 1, 3 to 6, 9, 10, 13 and 14, each for the reason below.

Fixed:

  • 2: the breaking fragment now says "This change requires no sibling version beyond those 0.33.0 requires, and raises no sibling package's version range." Our answer to finding 1 of the review of 0342d90c7552 no longer described the fragment. Its list gave the sibling versions 0.32.0 required, which the merge of main at c788d078c4 made false, so the list was cut. The new sentence names no version numbers, so it stays true when main raises a range.
  • 7: with no time, the held ending now says only "A wake for {agent} is held." (src/gate/reply-judge.ts:271), and makes no claim about when it's let go. Only a host's own upcomingWakes gives a held wake with no time, since the framework lists a provider cooldown only when its end is finite (src/framework.ts:6737).
  • 8: the held ending takes its time from the first certain held wake in the list's own order, soonest first (soonestFirst, src/gate/reply-judge.ts:262–265, which ifYouWait sorts by too, :282), rather than from the first one given. Here too, only a host's own list can hold two.
  • 11: onError's doc now says the next refusal takes a no-end refusal's place (src/gate/types.ts:375–376).
  • 12: the late-refusal test now checks the last millisecond before the stated end, through the file's lastMillisecond (test/event-gate-judge.test.ts:1368).
  • 15: the test's title now says that a batch due before the wait's end is the one delivered without the judge (test/event-gate-judge.test.ts:844).
  • 16: the description now says that only the ordering test's waits around a turn's start and failure hold the clock still (test/event-gate-judge.test.ts:2741–2747). Its waits for a turn to end and for the judge's call step the mocked clock (:2736, :2753–2754).

Left:

  • 1: the entries for a batch the judge is deciding, a held batch and buffered events test the gate's recipients, as main's entry for a rule's batch in its debounce window does (main at c788d078c4: src/gate/event-gate.ts:4380, :4385). None of them reads an event's recipients. Testing each event's recipients would change how the list counts every batch, main's entry included, so this change doesn't make it.
  • 3 and 6: the gate keeps one refusal. Each refused call replaces it, any REPLY or WAIT ends it, and so does a release (src/gate/event-gate.ts:4584, :4545, :4660). It doesn't order calls against each other. So a refusal reported after a release, or after a newer refusal, stands, and a verdict on a call asked earlier ends a newer refusal. Tests pin each of these (test/event-gate-judge.test.ts:1244, :1328, :1375). An ask while the hold still stands is refused before any request is sent (src/framework.ts:6954, before run() at :6965), and under "skip" its batch is held again. A batch held under a no-end refusal goes back to the judge at the next REPLY or WAIT, at a release, or at the end of a timed refusal that takes its place. The description's "One refusal, ended by any verdict" names this.
  • 4: a refused call has usually sent no request. The framework refuses it before sending (src/framework.ts:6954), and the gate records the refusal before the streak or its newest failure is touched (src/gate/event-gate.ts:4520–4522). So "failed its last N requests" holds of the requests sent. Our answer to finding 11 of the review of 3a9e9bf537d6 said the line was false once a refusal fell inside the streak, and that was wider than the code.
  • 5: the helpers bound their waits in mocked time. A real operation that outlasts that many rounds of event-loop turns would trip a bound or a mocked timer, but no run has shown one. At this head, the file has passed in CI and in four runs on a shared machine.
  • 9: 0.33.0 also renders every message of the preview on each build, and hashes each one a character at a time (keyOf, 0.33.0's src/gate/reply-judge.ts:156–160, :373–374). This change adds escaping, which is linear in the text. Its per-character width loops run only for a message past its 12,000-character bound, and are bounded by it (src/gate/reply-judge.ts:165). We haven't measured a build's time.
  • 10: the framework reads the clock once, so a live wait with an end always has time left to state, and one with no end states none (src/framework.ts:7578–7585). A retry of recorded waits that couldn't be read runs between that reading and the wait's lookup (src/provider-waits.ts:259). A slow retry shifts the stated end later by its length, so the held batch is asked that much later, and isn't lost.
  • 13: with the framework's own list, "Nothing runs it again" is the line for a start the list doesn't show, such as one parked behind an auxiliary call or one assembling its deferred pushes, as the description says. That's the cost of choosing the line's ending from the list the judge is given, rather than keeping a second reading of the framework's own. Our answer to finding 12 of the review of b5e853c014c3 no longer describes this head, since the list no longer counts such a start.
  • 14: the comment is 0.33.0's, from feat(gate): judge wake rules — ask a small model before waking #801, and this change doesn't touch it.
Credits

Rupert-2634 made these changes and wrote this reply, Ada-1017 made their design calls, and Abigail-2676 read the changes.

Rupert-2634 added 2 commits November 9, 2026 08:50
…a refused batch is held on its own

A judge call the framework would refuse used to build the agent's whole
context first, only to be refused at the call. To spare those builds,
the gate kept one refusal for all its rules: under "wake" it delivered
every judge rule's batch without asking while the refusal's stated
wait lasted, under "skip" it held them all, and a REPLY or WAIT on any
rule's call ended it. That window needed ordering rules for refusals
reported late, and its own wake-list wording.

The judge now asks first. ReplyJudgeDeps.refusal says whether the
framework would refuse the call on the judge's model for the agent now;
the framework answers it with the same four checks its membrane's
admission makes (judgeRefusalFor: a surgery, a stopping framework, a
provider wait on the model, an acceleration hold). A refused call reads
no context and sends nothing, and the gate decides it as before. The
judge picks the agent once, and the check and the build both take it. The
membrane's own admission still refuses a call whose hold began since,
and a judge built without the hook reads the context and leaves the
refusal to the call.

With a refused call costing no build, the gate keeps no refusal of its
own. Every batch is asked about. Under "wake", a refused batch is
delivered, logged and traced, and nothing is held, so gate_status names
no refusal; the rule's judgeState.last shows it. Under "skip", a
refused batch is held with its refusal and a timer of its own, to the
refusal's stated end or a release (providerCallsReleased). The rule's
next batch takes it in and is asked; refused again, the whole is held
to the new end. A verdict or failure on one rule leaves another rule's
held batch held. A held batch's first refusal is logged and traced, and
so is its first after a release or after a turn stops a call that took
it in; the rest are counted. gate_status names each held refusal by its rule, in rule
order, since two rules can now hold different refusals.

The five places that end a turn exhausted mark it failed for the judge
through one helper, failTurn. Its settle is optional, since a silent
start that throws before its stream marks the failure without settling
an open turn, as before.

In the judge's document, a bracket or backslash in a participant's
name or a wake's words now takes the backslash escape tool names
already had, where brackets were written as &anima-research#91; and &anima-research#93;: field()
is escapeText over labelName. A tool's name stays labelName alone,
since the message's quoting gives it the entities. Each value is
escaped once: an event's channel and author are quoted with
JSON.stringify and get only the entities on top, since JSON's own
escape already doubles a backslash and a further backslash escape
would make one read as four, which misquotes them; a bracket inside
the quotes, mid-line, can't open a label, so it needs none.
The one conflict is in test/event-gate-judge.test.ts, where main's tests of a batch longer than a spread can pass (anima-research#942) and this branch's refusal log-line test were both added after the same test. Both stay whole, this branch's first. No call this branch adds spreads a list as its arguments.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

/review

— Rupert-2634

@anima-review

anima-review Bot commented Nov 9, 2026

Copy link
Copy Markdown

Automatic context view of 66b3589f4108 merged onto 152ee76fbadd.

What a context view runs and what its checks judge: CHECKS.md.

The records of 54 of the 75 runs it could compare differ between the base and this head.

In the files profiled hosts loaded, 162 changed source lines that map to code ran in no profiled drive: src/framework.ts lines 7003–7004, 7008–7009, 7012–7019, 7420, 7682, 26937, 30114, 30418 and 30420–30421; src/gate/event-gate.ts lines 4158, 4169–4174, 4361–4364, 4369, 4379, 4411, 4422–4426, 4498–4500, 4502–4503, 4508–4509, 4513–4515, 4517, 4523, 4572, 4622, 4625–4647, 4650–4665, 4668–4678, 4681–4684, 4687–4689, 4705–4707, 4725, 4752–4756 and 5297–5298; and 42 more lines in 2 files. No profiled host loaded src/gate/types.ts (83 changed lines). 200 changed source lines map to nothing the host runs: 18 of src/framework.ts's, 79 of src/gate/event-gate.ts's, 1 of src/gate/index.ts's, 82 of src/gate/reply-judge.ts's, 1 of src/index.ts's, 4 of src/provider-waits.ts's and 15 of src/types/trace.ts's. The whole list is in the fold below.

1 run couldn't be compared; its reason is below.

Usage: the agents' requests, subagents' included, in the 75 runs, base → head: 210,075 → 210,081 tokens sent a request, 70.5% of them read from the cache, 145,843,898 → 145,848,824 written to it, 3,873,203 output and 24 thinking blocks dropped. The figures of 54 runs moved, for any kind of request; they're under "Usage that differs", below. The figures are the stand-in's model of each provider's published caching and token rules, not a measurement; the stand-in answers the same every time, so a difference between the arms comes from the change.

A model's account of those records:

Delta 1: Model-facing wake-rule instructions change

In summariser requests across the compression workloads, and in requests asking a model whether to wake the resident during the compared wake-gate conversations.

  • base (main): The summariser receives the earlier wake-rule tool definitions, while the wake-decision model is promised that deferred events will remain in context and be seen when the resident next wakes.
  • head (66b3589f4108): The summariser receives changed wake-rule tool definitions, while the wake-decision model is told only that events stored on arrival remain stored, and its accompanying conversation-and-event document also changes.

Delta 2: Settings-tool documentation changes

In settings-tool results during the tool-result guarding, lifecycle-change and compared wake-gate conversations.

  • base (main): The settings documentation uses the earlier tool descriptions, with its catalogue describing removal of a wake rule without mentioning pending batches awaiting a wake decision.
  • head (66b3589f4108): The settings documentation uses changed tool descriptions, with its catalogue warning that pending batches still follow the removed rule.
  • Also in: Later resident requests and previews that send nothing retain the revised settings documentation in those conversations.

Delta 3: Gate status adds a refused-call counter

In the compared wake-gate conversations, when removal of a resident-added wake rule returns a report of the wake rules' status. Follows from Delta 2.

  • base (main): The status report sent to the resident's model counts wake decisions and judge failures without a separate counter for calls the framework refused to send.
  • head (66b3589f4108): The report also includes a counter for judge calls the framework refused to send.
  • Also in: Later resident requests in the conversation where a failed judge leaves the resident asleep replay the expanded report alongside the revised settings documentation.

Delta 4: Resident cache markers move (the description doesn't mention it)

In the wake-gate conversation where a failed judge wakes the resident, around a debounced batch that the resident has partly answered.

  • base (main): The resident's model receives cache markers at the base's positions among the compiled messages.
  • head (66b3589f4108): The cache markers occupy changed positions among the same named messages.

Delta 5: Request bodies differ without renamed conversation material (the description doesn't mention it)

In resident requests during tool-result guarding, lifecycle changes and the compared wake-gate conversations, and in summariser requests during the guarding and lifecycle conversations.

  • base (main): The models receive the base's message contents and cache markers for the named conversation material.
  • head (66b3589f4108): The request bodies have changed contents or cache markers while retaining the same named conversation material.
Runs whose records differ: 54 of 75, with 5 changes
H1/continuation: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H1b/continuation/from-0.9.0: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H1b/continuation/from-0.23.0: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H1b/continuation/fresh: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H1/W1: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H4/W1: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H4b/W1: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C3h/W2: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H1/W2: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H1b/W2: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C5h/W5: 3 changes

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

Settings-tool documentation changes

At 13 steps; the quotes are from step templates.

Before:

    wake_remove_rule(name: string) — Remove a wake rule (gate.json policy) by name at runtime.

After:

    wake_remove_rule(name: string) — Remove a wake rule (gate.json policy) by name at runtime; a judge rule's pending batches still go where the rule as it was sends them.

Request bodies differ without renamed conversation material

At 13 steps; the quotes are from step 51116.g.

Before:

round + 51116.g.r1, body 42b283e8

After:

round + 51116.g.r1, body dc4d874e
C8h/W6: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C3h/W7/merges-refused: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H1/W7/merges-refused: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H1/W7/merges-rejected: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H2/W7/merges-refused: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H1/W7/chunk-refused: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C14h/W7/merges-refused: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C15h/W7/chunk-refused: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C16h/W7/chunk-refused: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C17h/W7/merges-refused: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C17h/W7/chunk-refused: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C3h/W8/unenforced: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C6h/W8/enforced: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H1/W8/enforced: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H4/W8/unenforced: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H3/W9: 3 changes

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

Settings-tool documentation changes

At 8 steps. Its before and after are quoted under C5h/W5, at step templates.

Request bodies differ without renamed conversation material

At 6 steps. Its before and after are quoted under C5h/W5, at step 51116.g.

C3h/W9: 3 changes

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

Settings-tool documentation changes

At 8 steps. Its before and after are quoted under C5h/W5, at step templates.

Request bodies differ without renamed conversation material

At 6 steps. Its before and after are quoted under C5h/W5, at step 51116.g.

C6h/W9: 3 changes

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

Settings-tool documentation changes

At 6 steps. Its before and after are quoted under C5h/W5, at step templates.

Request bodies differ without renamed conversation material

At 6 steps. Its before and after are quoted under C5h/W5, at step 51116.g.

H1/W9: 3 changes

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

Settings-tool documentation changes

At 7 steps. Its before and after are quoted under C5h/W5, at step templates.

Request bodies differ without renamed conversation material

At 6 steps. Its before and after are quoted under C5h/W5, at step 51116.g.

H1/W14: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C6h/W11: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H1/W11: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

P1/continuation: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

P2/continuation: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

P3/continuation: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

P4/continuation: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

P5/continuation: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H5/W12: 5 changes

Model-facing wake-rule instructions change

At 6 steps; the quotes are from step templates.

Before:

  letting the events go by means it won't act on them until something else wakes it (they stay in its context,
  so it will see them then).

After:

  letting the events go by means it won't act on them until something else wakes it (what was stored as it
  arrived stays stored for then).

Settings-tool documentation changes

At 3 steps. Its before and after are quoted under C5h/W5, at step templates.

Gate status adds a refused-call counter

At 2 steps; the quotes are from step templates.

Before:

"verdicts":{"reply":1,"wait":1,"error":2}

After:

"verdicts":{"reply":1,"wait":1,"error":2,"refused":0}

Resident cache markers move

At 2 steps; the quotes are from step 88667.part.y.

Before:

^ 6,35, body f534dfef

After:

^ 4,35, body 6ce9a8f0

Request bodies differ without renamed conversation material

At 51 steps. Its before and after are quoted under C5h/W5, at step 51116.g.

H5b/W12: 4 changes

Model-facing wake-rule instructions change

At 5 steps. Its before and after are quoted under H5/W12, at step templates.

Settings-tool documentation changes

At 21 steps. Its before and after are quoted under C5h/W5, at step templates.

Gate status adds a refused-call counter

At 28 steps. Its before and after are quoted under H5/W12, at step templates.

Request bodies differ without renamed conversation material

At 18 steps. Its before and after are quoted under C5h/W5, at step 51116.g.

H6/continuation: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H7/continuation: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H1c/W2: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H1d/W2: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C9h/continuation: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C11h/continuation: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C12h/continuation: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C20h/continuation: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C18h/continuation: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C13h/continuation: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C19h/continuation: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C21h/continuation: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

C23h/continuation: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

H1/W18: 1 change

Model-facing wake-rule instructions change

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

Runs that couldn't be compared (1)

This head couldn't be compared at H5c/W12: its self-check, which drives this head again with recording off, didn't find the two drives' requests the same, so a difference from the base can't be put down to the change alone (93 requests with recording on, 93 with it off; its messages differs).

Usage that differs: 54 of 75 runs, base → head
H1/continuation kind requests input read from the cache written to it output thinking dropped
agent 15 147,291 3,234,764 1,092,728 24,133 0
summariser 22 462,067 → 462,141 803,554 → 805,034 93,002 → 93,076 54,102 0
H1b/continuation/from-0.9.0 kind requests input read from the cache written to it output thinking dropped
agent 15 143,380 1,259,891 993,917 20,252 24
summariser 14 341,367 528,194 → 529,156 60,746 → 60,820 38,296 0
H1b/continuation/from-0.23.0 kind requests input read from the cache written to it output thinking dropped
agent 15 143,380 1,327,077 862,775 20,252 0
summariser 21 375,040 804,737 → 806,217 77,113 → 77,187 52,401 0
H1b/continuation/fresh kind requests input read from the cache written to it output thinking dropped
agent 78 286,615 6,908,754 2,980,345 122,172 0
summariser 67 1,301,765 → 1,301,987 2,464,716 → 2,469,230 221,111 → 221,333 145,091 0
H1/W1 kind requests input read from the cache written to it output thinking dropped
agent 192 1,167,886 41,157,946 3,123,362 236,990 0
summariser 87 1,577,199 → 1,577,421 3,311,858 → 3,318,000 177,032 → 177,106 174,000 0
H4/W1 kind requests input read from the cache written to it output thinking dropped
agent 206 1,150,617 42,313,313 2,818,410 250,641 0
summariser 88 1,525,874 → 1,526,096 3,514,791 → 3,521,007 184,921 → 184,995 176,000 0
H4b/W1 kind requests input read from the cache written to it output thinking dropped
agent 212 1,146,418 47,744,443 2,663,639 241,037 0
summariser 85 1,449,588 → 1,449,810 3,811,128 → 3,817,122 179,856 → 179,930 170,000 0
C3h/W2 kind requests input read from the cache written to it output thinking dropped
agent 147 5,841,443 6,327,183 2,861,592 179,757 0
summariser 81 2,650,483 → 2,650,705 3,117,342 → 3,122,966 295,168 → 295,316 162,000 0
H1/W2 kind requests input read from the cache written to it output thinking dropped
agent 147 635,688 26,473,291 10,166,262 181,036 0
summariser 123 2,782,111 → 2,782,333 4,746,672 → 4,755,404 346,030 → 346,178 246,000 0
H1b/W2 kind requests input read from the cache written to it output thinking dropped
agent 147 651,979 24,753,546 11,147,479 182,725 0
summariser 123 2,836,342 → 2,836,564 4,739,740 → 4,748,472 344,022 → 344,170 247,845 0
C5h/W5 kind requests input read from the cache written to it output thinking dropped
agent 27 0 3,524,279 → 3,524,559 3,797,623 → 3,798,253 68,198 0
summariser 15 425,312 → 425,382 543,911 → 544,873 115,966 → 116,114 30,000 0
C8h/W6 kind requests input read from the cache written to it output thinking dropped
agent 27 0 4,314,093 2,426,435 30,521 0
summariser 7 248,083 272,812 → 273,256 62,702 → 62,776 14,000 0
C3h/W7/merges-refused kind requests input read from the cache written to it output thinking dropped
agent 59 3,086,010 5,197,821 1,701,005 69,457 0
summariser 31 697,805 1,535,381 → 1,537,527 155,258 → 155,406 32,165 0
H1/W7/merges-refused kind requests input read from the cache written to it output thinking dropped
agent 59 365,297 13,699,570 2,370,770 79,969 0
summariser 36 722,845 1,934,400 → 1,936,916 165,560 → 165,708 42,165 0
H1/W7/merges-rejected kind requests input read from the cache written to it output thinking dropped
agent 42 109,678 8,206,592 3,422,600 49,542 0
summariser 10 160,352 453,356 → 453,948 131,084 → 131,232 20,000 0
H2/W7/merges-refused kind requests input read from the cache written to it output thinking dropped
agent 41 3,111 4,968,984 253,841 53,133 0
summariser 27 532,271 → 532,715 1,023,663 → 1,025,143 69,114 → 69,188 34,110 0
H1/W7/chunk-refused kind requests input read from the cache written to it output thinking dropped
agent 59 373,090 13,975,020 2,459,140 73,587 0
summariser 28 584,277 1,103,812 → 1,105,736 165,552 → 165,700 48,044 0
C14h/W7/merges-refused kind requests input read from the cache written to it output thinking dropped
agent 59 376,775 13,548,600 2,832,665 78,455 0
summariser 38 1,776,773 → 1,779,585 0 0 46,165 0
C15h/W7/chunk-refused kind requests input read from the cache written to it output thinking dropped
agent 59 355,394 13,849,614 2,424,895 69,267 0
summariser 28 594,632 1,092,970 → 1,094,894 163,002 → 163,150 46,055 0
C16h/W7/chunk-refused kind requests input read from the cache written to it output thinking dropped
agent 59 333,737 14,037,943 2,412,150 71,642 0
summariser 30 702,555 → 702,925 966,333 → 968,035 132,834 → 132,982 47,803 0
C17h/W7/merges-refused kind requests input read from the cache written to it output thinking dropped
agent 59 375,158 14,169,593 2,051,389 74,176 0
summariser 37 805,873 → 806,095 1,885,728 → 1,888,096 165,388 → 165,536 44,165 0
C17h/W7/chunk-refused kind requests input read from the cache written to it output thinking dropped
agent 59 396,516 13,723,056 2,493,041 75,613 0
summariser 31 634,642 → 634,716 1,195,984 → 1,198,056 167,408 → 167,556 52,055 0
C3h/W8/unenforced kind requests input read from the cache written to it output thinking dropped
agent 32 1,466,835 1,561,916 589,545 37,113 0
summariser 7 159,821 199,545 → 199,915 102,526 → 102,674 14,000 0
C6h/W8/enforced kind requests input read from the cache written to it output thinking dropped
agent 28 0 4,016,981 2,639,660 30,559 0
summariser 7 249,799 264,916 → 265,360 61,386 → 61,460 14,000 0
H1/W8/enforced kind requests input read from the cache written to it output thinking dropped
agent 30 82,873 4,256,996 2,647,298 38,726 0
summariser 8 273,019 302,642 → 303,160 63,510 → 63,584 16,000 0
H4/W8/unenforced kind requests input read from the cache written to it output thinking dropped
agent 34 23,492 478,809 346,169 19,848 0
summariser 1 7,319 0 49,069 → 49,143 2,000 0
H3/W9 kind requests input read from the cache written to it output thinking dropped
agent 49 13,657,707 → 13,658,127 1,390,596 1,668,172 50,817 0
summariser 1 44,916 → 44,990 0 0 2,000 0
C3h/W9 kind requests input read from the cache written to it output thinking dropped
agent 49 1,323,976 1,225,802 → 1,226,117 1,289,340 → 1,289,445 52,943 0
summariser 32 714,936 → 715,010 1,422,907 → 1,424,905 399,027 → 399,323 64,000 0
C6h/W9 kind requests input read from the cache written to it output thinking dropped
agent 52 84,064 6,970,269 → 6,970,584 3,793,772 → 3,793,877 52,392 0
summariser 41 762,583 → 762,657 1,818,929 → 1,821,593 329,594 → 329,925 82,000 0
H1/W9 kind requests input read from the cache written to it output thinking dropped
agent 52 189,718 → 189,788 9,926,744 → 9,926,954 4,177,967 → 4,178,107 57,851 0
summariser 38 787,582 → 787,656 1,663,543 → 1,665,985 317,853 → 318,184 76,000 0
H1/W14 kind requests input read from the cache written to it output thinking dropped
agent 9 19,070 1,613,759 636,498 10,584 0
summariser 2 26,218 53,996 → 54,070 56,024 → 56,098 4,000 0
transition 1 7,097 0 0 30 0
C6h/W11 kind requests input read from the cache written to it output thinking dropped
agent 26 0 3,700,887 3,129,050 28,847 0
summariser 6 227,651 332,560 → 332,930 138,065 → 138,139 12,000 0
H1/W11 kind requests input read from the cache written to it output thinking dropped
agent 28 80,281 4,048,614 2,866,842 36,507 0
summariser 7 250,857 350,302 → 350,746 190,892 → 190,966 14,000 0
P1/continuation kind requests input read from the cache written to it output thinking dropped
agent 15 0 1,183,772 1,556,914 35,099 0
summariser 22 0 0 1,360,960 → 1,362,632 54,102 0
P2/continuation kind requests input read from the cache written to it output thinking dropped
agent 15 1,221,746 3,139,456 0 35,099 0
summariser 22 1,362,453 → 1,364,125 0 0 166,102 0
P3/continuation kind requests input read from the cache written to it output thinking dropped
agent 15 147,291 3,166,341 1,161,151 24,133 0
summariser 22 462,067 → 462,141 716,916 → 718,248 179,640 → 179,862 54,102 0
P4/continuation kind requests input read from the cache written to it output thinking dropped
agent 78 286,166 7,903,455 9,158,247 136,066 0
summariser 61 1,149,186 → 1,149,408 2,237,272 → 2,241,416 190,050 → 190,198 132,102 0
P5/continuation kind requests input read from the cache written to it output thinking dropped
agent 15 0 2,839,523 1,484,463 27,940 0
summariser 22 0 0 1,361,145 → 1,362,817 54,102 0
H5/W12 kind requests input read from the cache written to it output thinking dropped
agent 84 51,197 → 51,337 8,889,710 → 8,896,836 1,221,438 → 1,222,173 68,546 0
concepts 26 82,771 0 0 1,878 0
judge 2 6,255 → 6,269 23,664 → 23,914 23,664 → 23,914 67 0
naming 1 617 0 0 9 0
relevance 2 10,035 0 0 42 0
review 1 1,352 0 0 55 0
H5b/W12 kind requests input read from the cache written to it output thinking dropped
agent 72 46,403 → 46,542 4,909,666 → 4,913,151 3,594,196 → 3,597,407 64,704 0
concepts 19 65,482 0 0 1,605 0
judge 2 1,492 → 1,502 684 → 685 684 → 685 67 0
naming 1 617 0 0 9 0
relevance 2 11,495 0 0 42 0
review 1 1,924 0 0 55 0
H6/continuation kind requests input read from the cache written to it output thinking dropped
agent 15 141,509 1,010,635 1,305,716 24,594 0
summariser 20 461,693 → 461,767 712,854 → 714,186 78,806 → 78,880 50,102 0
H7/continuation kind requests input read from the cache written to it output thinking dropped
agent 15 4,243,499 0 0 26,149 0
summariser 20 1,199,951 → 1,201,431 0 0 50,102 0
H1c/W2 kind requests input read from the cache written to it output thinking dropped
agent 147 637,626 25,441,174 10,971,724 174,105 0
summariser 123 2,826,779 → 2,827,001 4,723,190 → 4,731,922 345,632 → 345,780 247,845 0
H1d/W2 kind requests input read from the cache written to it output thinking dropped
agent 147 646,903 25,311,334 11,448,786 181,181 0
summariser 122 2,798,146 → 2,798,368 4,687,637 → 4,696,295 345,656 → 345,804 245,830 0
C9h/continuation kind requests input read from the cache written to it output thinking dropped
agent 15 147,291 2,773,046 1,356,930 24,133 0
summariser 21 445,011 → 445,085 755,473 → 756,879 89,192 → 89,266 52,282 0
C11h/continuation kind requests input read from the cache written to it output thinking dropped
agent 15 147,291 2,784,543 1,366,786 24,133 0
summariser 22 462,067 → 462,141 803,554 → 805,034 93,002 → 93,076 54,102 0
C12h/continuation kind requests input read from the cache written to it output thinking dropped
agent 15 1,097,317 908,575 508,866 24,133 0
summariser 19 456,394 → 456,468 707,050 → 708,308 84,890 → 84,964 48,102 0
C20h/continuation kind requests input read from the cache written to it output thinking dropped
agent 15 1,094,290 908,271 508,586 24,133 0
summariser 19 456,394 → 456,468 707,050 → 708,308 84,890 → 84,964 48,102 0
C18h/continuation kind requests input read from the cache written to it output thinking dropped
agent 15 147,291 2,930,333 1,400,959 24,133 0
summariser 32 364,311 1,165,505 → 1,167,799 82,863 → 82,937 74,102 0
C13h/continuation kind requests input read from the cache written to it output thinking dropped
agent 15 147,291 2,940,023 1,417,560 24,133 0
summariser 29 339,330 1,478,633 → 1,480,705 111,253 → 111,327 68,102 0
C19h/continuation kind requests input read from the cache written to it output thinking dropped
agent 15 147,291 3,167,405 1,179,518 24,133 0
summariser 17 165,241 1,852,992 → 1,854,176 129,501 → 129,575 44,102 0
C21h/continuation kind requests input read from the cache written to it output thinking dropped
agent 15 147,291 2,917,191 1,321,706 24,133 0
summariser 18 458,438 → 458,512 634,327 → 635,511 66,638 → 66,712 46,102 0
C23h/continuation kind requests input read from the cache written to it output thinking dropped
agent 15 147,291 3,234,764 1,092,728 24,133 0
summariser 22 494,665 → 494,739 785,302 → 786,782 78,806 → 78,880 54,102 0
H1/W18 kind requests input read from the cache written to it output thinking dropped
agent 6 14,883 1,016,484 596,000 8,708 0
summariser 3 27,862 98,636 → 98,784 49,318 → 49,392 4,001 0

The figures of the other 21 runs are the same in the base and this head.

Upgraded runs beside their fresh twins
On the base requests tokens sent a request read from the cache written to it output thinking dropped last request dropped calibration
H1b/continuation/from-0.9.0 15 159,813 52.6% 993,917 20,252 24 0 1.28, confirmed after a reset
H1b/continuation/fresh 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed
H1b/continuation/from-0.23.0 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed
On the head requests tokens sent a request read from the cache written to it output thinking dropped last request dropped calibration
H1b/continuation/from-0.9.0 15 159,813 52.6% 993,917 20,252 24 0 1.28, confirmed after a reset
H1b/continuation/fresh 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed
H1b/continuation/from-0.23.0 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed

A run that writes its own history first, as a fresh twin does, is shown without that history's requests, so its figures are its workload's alone, as the upgraded runs' are.

Changed source lines and deletions not seen to run, by file

src/framework.ts: lines 7003–7004, 7008–7009, 7012–7019, 7420, 7682, 26937, 30114, 30418 and 30420–30421 ran in no profiled drive; lines 4188–4189, 6792–6793, 6796–6797, 7005, 7010, 7020, 13370–13373, 13382–13383 and 13837–13839 map to nothing the host runs.

src/gate/event-gate.ts: lines 4158, 4169–4174, 4361–4364, 4369, 4379, 4411, 4422–4426, 4498–4500, 4502–4503, 4508–4509, 4513–4515, 4517, 4523, 4572, 4622, 4625–4647, 4650–4665, 4668–4678, 4681–4684, 4687–4689, 4705–4707, 4725, 4752–4756, 5297–5298 and 5301–5305 ran in no profiled drive; lines 46, 258–265, 270, 2314–2316, 2465–2466, 3022–3023, 3061–3062, 3703, 4153, 4175–4176, 4184–4185, 4337–4338, 4340–4345, 4409–4410, 4458–4459, 4462–4473, 4477–4478, 4504, 4623–4624, 4648–4649, 4666–4667, 4679–4680, 4685–4686, 4690–4701, 4746, 4849, 5299 and 5308–5309 map to nothing the host runs.

src/gate/index.ts: line 21 maps to nothing the host runs.

src/gate/reply-judge.ts: lines 164–167, 173–182, 228, 274–279, 440–442, 456–457, 462–463 and 623–631 ran in no profiled drive; lines 5–15, 17–31, 35–38, 47, 76, 95–100, 104, 112–115, 132–133, 136–137, 140–144, 155–156, 159–160, 168–169, 213, 216–217, 220–221, 272–273, 280–281, 283–286, 435–439, 458, 464, 481, 611–612 and 632 map to nothing the host runs.

src/gate/types.ts: no profiled host loaded it: changed lines 65–72, 74–77, 340, 342–350, 356–377, 385–386, 416–425, 432–438, 440–444, 463–467, 488–492, 667, 669–671 and 675.

src/index.ts: line 86 maps to nothing the host runs.

src/provider-waits.ts: lines 253–256 map to nothing the host runs.

src/types/trace.ts: lines 608–617, 622 and 630–633 map to nothing the host runs.

Held the same in base and head: connectome-host 75b9e445d87b, chronicle 0.6.0, context-manager 0.25.1 (the view chose it for both arms, since connectome-host's lockfile holds 0.25.0, which doesn't meet ^0.25.1 from the base), membrane 0.8.0.

@anima-review

anima-review Bot commented Nov 9, 2026

Copy link
Copy Markdown

Automatic review of 66b3589f4108.

About these findings

The findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits.

Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase.

Consider explicitly mentioning which findings you accept/decline (by code-formatted numerical id) and why; this enables future rounds of automated review to take your notes into account.

Findings, most severe first:

  • 1. should fix at src/gate/event-gate.ts:4355, src/gate/event-gate.ts:4414, src/gate/event-gate.ts:4754, src/gate/types.ts:371: A batch fired during a turn merges into a refusal-held batch without bypassing the refusal, so the turn's end asks nothing.
    • Failing case: Under onError "skip", hold a batch for a refusal with no stated end, start a turn, and let another batch of the same rule fire before the turn ends. If the underlying hold lifted without a release notification, the combined batch remains held indefinitely instead of being asked after the turn, even though the new batch should have triggered an ask.
  • 2. minor at src/gate/event-gate.ts:4633, src/gate/event-gate.ts:4704, src/gate/event-gate.ts:4709, src/gate/event-gate.ts:4702, src/gate/event-gate.ts:4398, src/framework.ts:6988: Releases aren't ordered against in-flight judge calls, so a refusal delivered after its hold's release installs a stale no-end hold.
    • Failing case: A judge call encounters an indefinite host hold, but its rejection is delivered asynchronously; the host releases the hold and calls providerCallsReleased before finishJudging receives the rejection. The release finds no held batch, and the later rejection creates an indefinite held batch with no timer or further release to make it leave.
    • Raised before as finding 3 of the review of a51ff97484b7 and answered: «the gate keeps one refusal. Each refused call replaces it, any REPLY or WAIT ends it, and so does a release (src/gate/event-gate.ts:4584, :4545, :4660). It doesn't order calls against each other. So a refusal reported after a release, or after a newer refusal, stands, and a verdict on a call asked earlier ends a newer refusal.» At this head, that doesn't hold: The answer said "the gate keeps one refusal" and that "any REPLY or WAIT ends it". At this head each held batch carries its own refusal (event-gate.ts:4424), and finishJudging (:4563-4623) leaves other rules' held refusals alone.
  • 3. minor at src/provider-waits.ts:421, src/provider-waits.ts:258, src/framework.ts:7636, src/gate/event-gate.ts:4671: The unreadable-records hold lifts without notifying the gate while its refused batches have no timer.
    • Failing case: If recorded provider waits cannot be read, a judge call is refused without an end and its batch is held under skip; when a later active() or list() successfully reloads the records, load() clears the hold but never calls providerCallsReleased(), so the batch remains held indefinitely unless another batch or unrelated release arrives.
    • Raised before as finding 10 of the review of b5e853c014c3 and answered: «the onError doc now says a refusal with no end, or no time left, holds its batches until a later call gets a verdict or the gate is told the hold ended or was released». At this head, that doesn't hold: The answer quoted the onError doc as holding batches "until a later call gets a verdict or the gate is told the hold ended". At this head, types.ts:371-373 says the batch waits for a release or is taken in by its own rule's next batch, and a verdict on another rule's call doesn't release it. The limitation is still documented in that narrower form.
  • 4. minor at changelog.d/reply-judge-review.added.md:3, changelog.d/reply-judge-review.changed.md:2, src/gate/reply-judge.ts:615, src/gate/reply-judge.ts:620, change-text.md:130, src/gate/reply-judge.ts:95, src/framework.ts:6988: Claims that a refused call reads nothing and builds no request overlook holds that begin after the preliminary check.
    • Failing case: If a provider hold starts while preview or the asynchronous wake provider is running, the judge has already read the context and built a request when membrane admission refuses it; a host relying on the fragments' unconditional claim would incorrectly expect that work not to occur.
  • 5. minor at src/gate/event-gate.ts:5303, src/framework.ts:6746, src/framework.ts:6756: Refusal notices quote surgery and stopping errors in host-internal terms without saying what happens to held events.
    • Failing case: During a live surgery, gate_status says the judge call was refused because a live operation 'holds the store' and that events are held, but does not say the judge will retry when the operation ends or that the agent cannot release the hold; the stopping refusal similarly reports 'provider admission closed' without explaining its consequence.
  • 6. minor at src/gate/reply-judge.ts:621, src/gate/reply-judge.ts:625, src/framework.ts:6992, src/framework.ts:6988: The too-long retry rebuilds context without repeating the preliminary refusal check.
    • Failing case: The first judge request fails with context_length and a stated provider wait, which the framework records, or another call installs a hold while it is in flight: the retry nevertheless runs preview again before its membrane admission refuses it, contrary to the promise that a call already refused reads no agent context.
  • 7. minor at src/framework.ts:12762, src/framework.ts:5574, src/gate/event-gate.ts:4671: Agent removal notifies the gate only when the agent had a cooldown, ignoring provider waits that held the judge.
    • Failing case: With two gate recipients, the selected agent has an indefinite wait on the judge model but no primary-turn cooldown, so a batch is refused and held; removing that agent changes which remaining recipient the judge would select, but skips providerCallsReleased(), leaving the batch without a timer or release.
  • 8. minor at src/gate/reply-judge.ts:327, src/gate/reply-judge.ts:275, src/gate/reply-judge.ts:298, src/gate/reply-judge.ts:380: The agent's name is escaped in one prompt sentence but interpolated raw in other prompt, wake, failed-turn and question text.
    • Failing case: An agent name containing a newline followed by [other] or </if_you_wait> creates an unindented label or closes the question's tag, even though the same name is escaped when identifying its messages.
    • Raised before as finding 2 of the review of 8bff1c4ba2c2, fixed then; this is a case the fix leaves.
  • 9. minor at src/gate/event-gate.ts:4498, src/gate/event-gate.ts:4509, src/gate/event-gate.ts:4513, src/gate/reply-judge.ts:290, src/supplied-text.ts:141: ifYouWait applies field quoting to whole wake descriptions, double-escaping rule names already quoted as JSON.
    • Failing case: A pending rule named a"b is first rendered with JSON's escaped quote, then the judge sees the backslash doubled, changing the displayed name and making its quoted field appear to end at the embedded quote; names containing backslashes or notice openings are likewise altered.
  • 10. minor at src/gate/reply-judge.ts:222, src/gate/reply-judge.ts:228: The tool-input fallback's unprotected String(value) can throw when JSON.stringify fails.
    • Failing case: A circular null-prototype object used as a tool input makes JSON.stringify throw and then makes String(value) throw too, so rendering fails instead of showing safe fallback text and an onError: "skip" judge drops the batch.
  • 11. minor at src/gate/reply-judge.ts:279, src/gate/reply-judge.ts:290, test/reply-judge.test.ts:594: The failed-turn line asserts nothing runs the turn again when a certain queued wake lacks the optional kind.
    • Failing case: A host supplies { certain: true, what: 'a wake already queued (x)' }, as the existing test does: the judge reads “Nothing runs it again” immediately above “as soon as it is free: a wake already queued (x)”, which can lead it to wake the agent unnecessarily.
  • 12. minor at test/provider-waits.test.ts:865, test/provider-waits.test.ts:880: The single-clock-reading test accepts any matching active() call rather than tying the admission read to the stated wait.
    • Failing case: An implementation that consults active() twice, decides from the first reading and derives retryAfterMs from the second, still passes whenever the second timestamp matches until minus the stated wait, despite the race this test names.
    • Raised before as finding 19 of the review of b020df3085d3, fixed then; this is a case the fix leaves.
  • 13. minor at src/framework.ts:13366, src/gate/event-gate.ts:2312, src/gate/event-gate.ts:3019, src/gate/event-gate.ts:4336, src/gate/event-gate.ts:4408, src/gate/event-gate.ts:4745, src/gate/reply-judge.ts:76, src/gate/reply-judge.ts:494, test/event-gate-judge.test.ts:90: Added or rewritten comments narrate private implementation and test helpers, which aren't permitted comment kinds.
    • Failing case: Editors encounter these comments on private helpers, state and constants even though they are none of the permitted comment kinds; the implementation narration can go, and any rationale worth preserving belongs in the change description or commit message.
    • Raised before as finding 12 of the review of 462f7dd2bcd9 and answered: «the comments this change added that aren't doc comments of exported surface are gone.» At this head, that doesn't hold: The answer said the comments this change added that aren't doc comments of exported surface are gone, but failTurn's new private doc comment (framework.ts:13366) is in the diff at this head.
  • 14. minor at src/gate/event-gate.ts:4560, test/event-gate-judge.test.ts:999: finishJudging's comment says each verdict is traced, but repeat refusals of a held batch are counted untraced.
    • Failing case: Under onError: "skip", a held batch refused again at its stated end increments the refusal count but emits no second verdict trace, as the test checks; a maintainer relying on the comment would expect a trace for every refusal.
Raised before, still answered (15)

…sal after a release holds nothing, and the retry asks first

A batch a judge rule fires while a turn or the host's quiesce holds it
merged into the rule's batch held for a refusal, which kept its refusal,
so the turn's end asked nothing: under a refusal with no end the whole
waited for a release, though onError's doc says a batch its rule fires
meanwhile takes the held events in and is asked about. Now new events
merged in for a turn or quiesce end the held batch's refusal and its
timer, so the whole is asked once the turn or quiesce ends, and held
again if it's refused again. A held batch its rule adds nothing to still
waits through turns' ends for its refusal's end or a release.

A release that came while a call was out already ended the call's
refusedBefore mark, but a refusal the call then reported held its batch
for a hold that may be the one released. Now such a refusal holds the
batch without it, and the batch is asked again once no turn or quiesce
holds it. That refusal was logged and traced as the first after the
release, so the held batch carries refusedBefore, and the ask again, if
refused, is only counted.

The judge asks the framework whether it would refuse the call again
before it rebuilds a request refused as too long, so a wait the refusal
stated stops the retry before its preview. An agent's end tells the
gate (providerCallsReleased) whether or not the agent had a provider
cooldown, since the judge may then read another agent.

A wake's words and its time in words in <if_you_wait> are kept to one
line with the entities and no backslash escape: mid-line, a bracket
opens no label, and the gate's own words already quote a rule's name as
JSON, whose backslashes field() doubled.

The single-reading test now asserts exactly one timed read. failTurn's
private doc comment goes, and finishJudging's doc no longer says every
verdict is traced.
@nissa-seru
nissa-seru force-pushed the fix/reply-judge-refused-calls branch from 66b3589 to 733e82e Compare November 9, 2026 17:19
Rupert-2634 added 2 commits November 9, 2026 13:14
main's anima-research#1000 tells an agent a failure in the framework's words, never a provider's. The refused call's notice in gate_status and judgeState.last.reason now take the framework's words too ("the framework refused to send the call"), since a hold's own words can carry a provider's (a recorded provider wait's reason, a cooldown's); the [gate] line and the trace's hold keep the hold's words, as main keeps a failure's own text for operators. The judge's throws this branch adds are JudgeFailure, as main's are. At the five places that end a turn exhausted, failTurn settles with what main now passes there (the framework's words and the cause), and traces main's shownReason. main's four tests of a judge failure's words come in beside this branch's tests, and its import of JudgeFailure beside this branch's imports. This branch's tests of a refusal's notice and reason take the new words, and two test titles that said gate_status names the hold are renamed; the changed fragment says which places give which words. main's 0.33.0 refusal test, which this branch had already replaced, stays out, and main's assertion in it that an error the framework didn't write reads "the call failed" is pinned by the judge-keeps-failing test main also changed.
main took anima-research#361 (a turn refused for budget waits for compression) and released 0.34.0. The merge is clean. The next commit refuses the reply judge's calls for an agent whose turn waits for compression, as anima-research#361 meets this branch.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Head 733e82ecba answered the automatic review of 66b3589f4108, and head 0ee170768a, which merges main at 9d57cc8138 into it, keeps those answers. They fix 1, 2, 4, 6, 7, 9, 12, 13 and 14, and leave 3, 5, 8, 10 and 11, each for the reason below. The line cites below are at 0ee170768a. The push of 733e82ecba also rewrote the branch's commit messages to drop a trailer, so every commit's hash changed and every tree stayed the same.

Fixed:

  • 1: a batch a rule fires while a turn or the host's quiesce holds it now ends the refusal of the rule's held batch it joins, and that refusal's timer (src/gate/event-gate.ts:4427). The whole is asked when the turn or quiesce ends, and held again if it's refused again. A held batch its rule adds nothing to still waits through turns' ends for its refusal's end or a release. The test that pinned the old behaviour now pins this (test/event-gate-judge.test.ts:1197).
  • 2: a release now marks each call that's out (src/gate/event-gate.ts:4730). A refusal such a call reports holds its batch without the refusal, and the batch is asked again once no turn or quiesce holds it (:4648, :4663–4666). That refusal is logged and traced as the first after the release, so a refusal of the ask again is only counted (test/event-gate-judge.test.ts:1229). Our answer to finding 3 of the review of a51ff97484b7 described the gate's one refusal, which no longer exists. onError's doc now says this (src/gate/types.ts:374–375).
  • 4: the .changed fragment now says the framework's judge learns a call would be refused before it reads the context, and again before it rebuilds a request refused as too long, "so a call it would refuse then builds no request". The .added fragment says the same.
  • 6: the judge asks refusal again before it builds the retry of a request refused as too long (src/gate/reply-judge.ts:635–636; test/reply-judge.test.ts:144).
  • 7: an agent's end now tells the gate whether or not the agent had a provider cooldown (src/framework.ts:12747), since the judge may then read another agent.
  • 9: a wake's words and its time in words are now kept to one line with the entities and no backslash escape (src/gate/reply-judge.ts:300). Mid-line, a bracket opens no label, and the gate's own words already quote a rule's name as JSON (test/reply-judge.test.ts:682).
  • 12: the test now asserts exactly one timed read, and that the stated wait is the one it found (test/provider-waits.test.ts:880–882).
  • 13: failTurn's private doc comment is gone (src/framework.ts:13343). The other comments cited reword main's own comments, which this change would otherwise leave untrue. Our answer to finding 12 of the review of 462f7dd2bcd9 missed that failTurn's comment was new.
  • 14: finishJudging's doc, which is main's, now says each verdict is counted and traced "but for a held batch's later refusals" (src/gate/event-gate.ts:4574–4576).

Left:

  • 3: this is the documented limit. A refusal with no end waits for a release or for its rule's next batch (src/gate/types.ts:370–373), and the .changed fragment says one that lifts unannounced leaves the batch held until then. With 1's fix, that next batch is asked even when it fires during a turn. Our answer to finding 10 of the review of b5e853c014c3 quoted the earlier wording.
  • 5: the notice says the events are held for the judge, which is what happens (src/gate/event-gate.ts:5327). Because main now tells an agent a failure in the framework's words (fix: a failure is told in the framework's words, never the provider's #1000), from 0ee170768a on the notice doesn't quote the hold: it reads "Rule "NAME"'s reply judge call was refused until END: N events held for the judge."
  • 8: main writes the agent's name as it is in the prompt, the list and the question (src/gate/reply-judge.ts:337, :308–309, :390), and the failed-turn line follows them (:285). It's escaped where the judge matches the agent's own message labels (:344). The instructions tag replaces each character outside [A-Za-z0-9_-] with _ (:388), as main does. The name is the host's own configuration.
  • 10: a tool call's input is the model's call parsed from JSON, so it can't be circular or have a null prototype.
  • 11: kind is how the line knows what runs the turn (src/gate/reply-judge.ts:286–288). A host that leaves it out gets the ending that errs toward waking the agent, as the description says, and the test pins that (test/reply-judge.test.ts:621).
Credits

Rupert-2634 made these changes and wrote this reply, Ada-1017 made their design calls, and Cordelia-1930 read the changes and the rewrite of the branch's commit messages.

— Rupert-2634

Rupert-2634 added 5 commits November 9, 2026 14:13
…s for compression

A turn whose compile is refused for budget now waits for compression (anima-research#361). The reply judge's call about that agent was still made, on a dry compile of the window the waiting turn couldn't send.

The framework now refuses the call without making it, as a refusal with no stated end, while the agent's turn waits. Under "skip" the batch is held; under "wake" it goes to delivery, and its request joins the wait's wakes. The wait's end, whether it's released, stopped or removed, tells the gate the agent's calls may go again (providerCallsReleased), so a held batch goes back to the judge.

Two tests hold a judge rule's batch while the turn waits, one ending the wait by its release and one by a stop. The docs and the changed fragment that list what the framework refuses a judge call for now name this too.
…wait for compression among the refusals and releases

0.34.0 is now the latest release. Each sentence that said what 0.33.0 did was read against 0.34.0, which does the same in every case, so each now names 0.34.0. The breaking fragment's sibling versions are those 0.34.0 requires, which this change leaves as they are. The changed fragment's first bullet says that in 0.34.0 a judge call about an agent whose turn waited for compression wasn't refused, and went on to a dry compile of the window that turn couldn't send, and its second names that wait's end among the releases.
…judgeRefusalsMayHaveLifted()

providerCallsReleased() is new in this change and unreleased. Two of the six places the framework calls it, a surgery letting go of the store and a turn's wait for compression ending, end holds that aren't on provider calls. What each call tells the gate is that a hold that refused the judge's calls may have lifted, so it's named for that now, before a release would make a rename a breaking change. Its doc lists the agent's end among its causes, as the framework calls it there. The added fragment's list of where the framework calls it now names the end of a turn's wait for compression, which the previous commit left out. No behaviour changes.
…-calls

Five files conflicted. The docs keep both sides: the trace's woke, JudgeConfig, UpcomingWake and its certain, the verdicts with refused, the judge parameter's text, health's upcoming wakes and EventGate.upcomingWakes. The failure log line takes main's "is delivered to wake the agent" and keeps this branch's "is dropped without waking the agent". A reply's delivery takes main's answeredWhenJudged mapping, with this branch's policyName for deliverOrBuffer. A refused call keeps this branch's refusedJudging, which under "wake" delivers the batch and its queue with their writes, as main's path for a call that made none did. The debounce entry keeps this branch's condition, a judge rule's batch with no judge wired listed as a debounce's, with main's per-agent certainty.

Four tests are turned to the merge. main's debounce-seen-wake case for a refused call traces 'refused', this branch's verdict for it. main's strict-rules test of a batch whose line can't be written quotes the error, as this branch's logUnsent does. This branch's tests of a refused batch decided once the refusal's wait ends, and of a batch queued behind a call delivered with it on a reply, read the wake's writes where they read a "Gate: 2 events matched" line, which main no longer writes for events stored at arrival; their gate is told the agent hasn't answered (isAnswered), so a reply keeps each event's write.
… certainty, as anima-research#972 meets them

anima-research#972 lists a queued wake, and a turn a provider hold keeps, as only maybe waking the agent when every request in it reads rows, since the scheduler drops such a wake once the agent has answered them. The reply judge's failed-turn line chose its ending only from certain wakes, so such a turn read as nothing running it again. It now says, after a certain queued wake and a certain held one, that a queued wake, else the soonest held one, may be dropped without running a turn, and that if it is, anything that woke the last turn and hasn't been answered stays unanswered until something else wakes the agent, with the held wake's time where it has one.

Events buffered behind a turn or the host's quiesce go out through the same delivery as a fired debounce's batch, so their wake carries the events' writes and starts no turn once the agent has answered every one. Their entry in upcomingWakes is now certain only where a debounced batch's is, by one helper both entries call (surelyWakes): delivering events surely wakes one of the agents named when one of them is given an event without a message of its own whose write was placed for it; otherwise it only may, since a host whose scheduler drops such a wake by its WakeProvenance.writes, as AgentFramework's does, starts no turn for an agent that has read and answered every one. UpcomingWake.certain's doc names them, and the changed and fixed fragments say both.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant