Repository navigation
feat(scheduler): messageQuietPeriod — one turn per message burst (carries #179) - #856
rufalupa666-netizen wants to merge 6 commits into
Conversation
GateOptions.messageQuietPeriod { quietMs, maxWaitMs? }: a message-driven wake
(MCPL channel-incoming / push-event the policies would trigger) is held until
no further message has arrived for quietMs, capped at maxWaitMs (default 30 s)
from the first held message, then fires once. Messages still enter context at
arrival and no batch summary is added; an agent already mid-turn when the hold
ends is not re-woken. Scheduled wakes, tool results and heartbeats are
unaffected. Default off.
Why: a caption and its forwarded images, or a reply Discord split into several
messages, woke residents once per message — and in rooms with several AIs each
part triggered replies from all the others. Measured gaps (4,758 messages):
split parts arrive 0-2 s apart; caption+forward within ~10 s.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ima-research#179 review) From the 10/20 review of anima-research#179 and Greptile's 9/28 findings, on top of Ian's commit rebased onto main: - An agent mid-turn when a hold ends gets its wake after the turn, not never: the hold fires through the debounce path (fireBatch), so it is buffered for an agent in a turn or a quiesced host and delivered at onInferenceEnded / resume. (Review 1, blocking.) - The held event is shaped as a debounce policy's, routeChannelId included, so a held DM's wake routes its reply back to the DM. (Review 2, blocking.) - Holds are keyed by channel (the registered id, else the event's own), so chatter in one channel holds no other channel's wake. - dispose() clears pending holds. - A message the policies didn't trigger (a rate-limited part of the same burst) extends an open hold for its channel but opens none. - The heartbeat (serverId 'heartbeat') is never held. - maxWaitMs is the operator's cap even below quietMs; it is no longer raised to quietMs. - A hold that ends while the agent sleeps is dropped and counted as suppressed, as the messages' own wakes would have been. - deliverEvents writes no [Gate: …] line for a quiet-period batch: its messages are the newest entries right above the wake. The wake's reason is 'gate:quiet-period'; mixed with a debounce policy's events the line and reason are the debounce's. - Tests pin each of the above, the wake after onInferenceEnded (the old test 3 pinned the drop), and the framework's pass-through of the option; the changelog fragment and the option's doc say what holds. Left as `main` has it for a debounce batch, and said in the description: the wake fans out to every agent (the framework's mayTakeGateRoute keeps a fork's route to its home), and tune-out reads the held message's verdict as the gate's no. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SkepYxcPem6pmgRxKd2RBY
There was a problem hiding this comment.
Automated review
Recommendation: fix, then merge. 1 high severity finding should be fixed first.
Reviewed commit bbd1a80 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 fits. It adds an opt-in, off-by-default wake-holding option to the existing event gate, and the held wake is delivered through the gate's own debounce batch path.
5 reasons with sources
The PR follows the contributing guide's conventions.
- The change stays inside the event gate, which already owns wake timing: debounce, judge batches, sleep and quiesce. The hold is added next to the existing debounceTimers and judge state. It ends through fireBatch, a function factored out of the debounce path, so the gate does not get a second delivery path. The framework only passes the option through, in one line. (src/gate/event-gate.ts)
- It is opt-in and off by default, and the only new public surface is one GateOptions field and one optional GateDecision flag.
messageQuietPeriod?: { quietMs, maxWaitMs? }sits next to the other GateOptions such as judge.quietHeld?is optional on GateDecision. When the option is unset, behaviour is unchanged. (src/gate/types.ts) - The README says almost nothing about the gate's scope, so this verdict rests on how the code is organised. Searching the README turns up no description of the gate or of debouncing. Nothing in it rules out a per-channel quiet period, and nothing asks for one. (README.md)
- The PR follows the contributing guide: a changelog fragment, tests, numeric test counts, and the AI-authorship footer. It adds the fragment
changelog.d/feat-gate-message-quiet-period.added.md, addstest/event-gate-quiet-period.test.ts, and reports 8533 tests with 8433 passing, 1 failing (also failing onmain) and 99 skipped. It also credits the original contributor of #179 and says what it left unchanged. (CONTRIBUTING.md) - A maintainer may want to look at the overlap with
debouncepolicies, which also batch wakes and give the gate a second batching concept. The quiet period works per channel and applies to messages across all policies, while debounce works per policy, so the two differ. Both feed the same fireBatch. The PR leaves fork routing and tune-out asmainhandles them for debounce batches, and the code review has flagged how those see held messages. (src/gate/event-gate.ts)
Findings
8 findings of medium or high severity, see the inline comments:
- High severity. A privileged user's message to a sleeping agent is held, then dropped as asleep.
- Medium severity. Any skipped message, not just a rate-limited part of the burst, extends an open hold.
- Medium severity. quietMs/maxWaitMs are not checked for finite, in-range values.
- Medium severity. Held ambient messages lose the reply route to their own channel.
- Medium severity. A held message reaches the framework as untriggered: fork routing, targetAgents and tune-out all see a 'no'.
- Medium severity, test coverage. Tests depend on tight real-time margins and are likely to be flaky.
- Medium severity, test coverage. No test for a privileged message, or a held message, while asleep.
- Medium severity, test coverage. Missing tests for ambient routing, push-event holds and mixed debounce/quiet batches.
5 more findings, less severe
- Low severity, documentation,
src/gate/types.ts:233-236. The doc says every held wake carries the burst's route, but only addressed messages get one. The option's doc and the changelog fragment say the held wake 'carries the burst's route'.wakeProvenancesetsrouteChannelIdonly when the chosen event is addressed. An ambient burst's wake carries no route and falls back to the most-recent-inbound locus. The doc should say so, or the code should route ambient bursts as well. - Low severity, maintainability,
src/gate/event-gate.ts:3146-3156. The decision trace does not show that a message was held. For a held message, thegate:decisiontrace reportstrigger: falseunder the matched policy name and has noquietHeldfield. Operators reading traces therefore cannot tell a held wake from a policy skip. The default-policy stats also count the event as triggered (line 3117) even though it did not trigger. AddquietHeldto the trace. - Low severity, maintainability,
src/gate/event-gate.ts:3585-3589. A no-op placeholder timer is created and then cleared at once.holdForQuietPeriodstoressetTimeout(() => {}, 0)only so thatarmQuietHoldcan clear it straight away. This allocates a timer for nothing and makes the code harder to read. Make the timer field optional, or arm the hold directly. - Low severity, maintainability,
src/gate/event-gate.ts:3558-3580. The PendingEvent construction is copied from handleDebounce.holdForQuietPeriodrepeats the ~20-line PendingEvent literal fromhandleDebounce(lines 3631-3650): snippet truncation, channelLabel, authorId, addressed, routeChannelId, messageId and eventId. Any future fix to how events are shaped, such as route resolution, must now be made in two places. Pull it out into a sharedpendingEventFor(policyName, info, inContext)helper. As written, it also callsrouteChannelFortwice per held message: once inquietKeyForand once here. - Low severity, documentation,
src/gate/event-gate.ts:1998-2002. The comment names fireDebounce, but the hold goes through fireBatch. The field comment says the hold fires 'through the debounce path (fireDebounce)'.fireQuietHoldactually callsfireBatchdirectly and skipsfireDebounce's judge branch. The comment should namefireBatch.
For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.
| * now otherwise. */ | ||
| private fireQuietHold(key: string): void { | ||
| const hold = this.quietHolds.get(key); | ||
| this.quietHolds.delete(key); | ||
| if (!hold || hold.events.length === 0) return; | ||
| const events = hold.events; | ||
| if (this.sleepUntil > this.now()) { | ||
| this.sleepSuppressed += events.length; | ||
| this.emitTrace({ | ||
| type: 'gate:decision', | ||
| eventType: events[events.length - 1].eventType, | ||
| serverId: events[events.length - 1].serverId, | ||
| channelId: events[events.length - 1].channelId, | ||
| matchedPolicy: 'asleep', | ||
| trigger: false, | ||
| behavior: 'skip', | ||
| timestamp: this.now(), | ||
| }); | ||
| return; |
There was a problem hiding this comment.
High severity. A privileged user's message to a sleeping agent is held, then dropped as asleep.
evaluate deliberately lets a privileged author's message through while asleep (lines 3063-3083) so it can wake the agent. With messageQuietPeriod on, that message's trigger becomes a quiet hold (line 3138). When the hold ends, fireQuietHold checks only this.sleepUntil > this.now() and drops the whole batch as 'asleep', without looking at who wrote it. So with the option on, a privileged ping can never wake a sleeping agent, which breaks a feature the sleep design documents. Either skip holding when the agent is asleep and the author is privileged, or keep privileged events when the hold ends. No test covers this case.
| } else if (this.quietHolds.has(key)) { | ||
| this.armQuietHold(key); | ||
| } |
There was a problem hiding this comment.
Medium severity. Any skipped message, not just a rate-limited part of the burst, extends an open hold.
The else if (this.quietHolds.has(key)) branch re-arms the hold for every untriggered channel-incoming or push-event in the channel, whatever made it untriggered. That includes messages a skip/never policy rejects on purpose, such as bot output, ambient chatter or other agents' messages. In a busy channel where the policies skip most traffic, an addressed human message's wake is therefore pushed back to the full maxWaitMs (30 s by default), well past quietMs. The comment and the option's doc justify this only for rate-limited parts of the same burst. Restrict re-arming to rate-limit denials, or to the same author.
| this.quietMs = Math.max(0, opts.messageQuietPeriod?.quietMs ?? 0); | ||
| // The cap is the operator's: a maxWaitMs below quietMs ends the hold at | ||
| // maxWaitMs, not at quietMs. | ||
| this.quietMaxWaitMs = Math.max(0, opts.messageQuietPeriod?.maxWaitMs ?? 30_000); |
There was a problem hiding this comment.
Medium severity. quietMs/maxWaitMs are not checked for finite, in-range values.
The constructor clamps only with Math.max(0, …), unlike judgeTimeoutMs (line 2185), which requires Number.isFinite(...) && > 0. A NaN maxWaitMs, for example from a malformed or string-typed host config, gives a NaN wait in armQuietHold. Values above about 2^31-1 ms overflow the timer. Node's setTimeout treats a NaN or out-of-range delay as 1 ms, so the hold would fire almost at once and silently stop batching, with no error or log. Validate the values and warn on bad ones.
| if ( | ||
| this.quietMs > 0 && | ||
| info.serverId !== 'heartbeat' && | ||
| (info.eventType === 'mcpl:channel-incoming' || info.eventType === 'mcpl:push-event') | ||
| ) { | ||
| const key = this.quietKeyFor(info); | ||
| if (decision.trigger) { | ||
| this.holdForQuietPeriod(key, info); | ||
| decision = { ...decision, trigger: false, quietHeld: true }; | ||
| } else if (this.quietHolds.has(key)) { | ||
| this.armQuietHold(key); | ||
| } | ||
| } |
There was a problem hiding this comment.
Medium severity. Held ambient messages lose the reply route to their own channel.
On the direct path (framework.ts ~14706-14727), every triggered channel-incoming wake carries channelId: event.channelId, so the turn's speech goes back to that channel, whether or not the message was addressed. With the hold, the wake's route comes from wakeProvenance, which sets routeChannelId only for addressed events (lines 329-331). An ambient burst's wake then falls back to the process-wide most-recent-inbound locus, which the framework comment at framework.ts 4316-4320 names as the bug that path fixed. Turning on the option therefore changes where replies to ordinary channel chatter are posted. The tests check only the addressed case.
| if (decision.trigger) { | ||
| this.holdForQuietPeriod(key, info); | ||
| decision = { ...decision, trigger: false, quietHeld: true }; | ||
| } else if (this.quietHolds.has(key)) { |
There was a problem hiding this comment.
Medium severity. A held message reaches the framework as untriggered: fork routing, targetAgents and tune-out all see a 'no'.
Rewriting decision.trigger to false makes ChannelRegistry/PushHandler stamp triggerInference: false. Several things change as a result. The stored message gets triggered: false. routeConversationIncoming queues no wake for the channel's fork. deliverTo / targetAgents targeting is ignored, because the later wake goes to every name getAgentNames returns, the dormant trunk template and every fork included. The tune-out coordinator receives the resident verdict as false (framework.ts 14689), so subconscious wakes it depends on are never armed, and coalescingSubject does not ride the wake. The debounce path behaves the same way, but it only applies to operator-chosen policies; this option applies to every message, so it alters these subsystems across the board. A conversation-router or tune-out deployment that enables it will misroute wakes.
|
|
||
| it('caps the hold at maxWaitMs under a continuous stream', async () => { | ||
| const { gate, wakes } = makeGate({ quietMs: 50, maxWaitMs: 120 }); | ||
| const start = Date.now(); | ||
| while (Date.now() - start < 200) { | ||
| gate.evaluate(msg('chatter')); | ||
| await sleep(25); | ||
| if (wakes.length > 0) break; | ||
| } | ||
| assert.equal(wakes.length, 1, 'woke despite continuous messages'); | ||
| assert.ok(Date.now() - start < 190, 'within roughly the cap'); | ||
| gate.dispose(); | ||
| }); | ||
|
|
||
| it('a maxWaitMs below quietMs is the cap, not raised to quietMs', async () => { | ||
| const { gate, wakes } = makeGate({ quietMs: 300, maxWaitMs: 40 }); | ||
| gate.evaluate(msg('one')); | ||
| await sleep(100); | ||
| assert.equal(wakes.length, 1, 'the hold ended at maxWaitMs'); | ||
| gate.dispose(); | ||
| }); | ||
|
|
||
| it('an agent mid-turn when the hold ends gets the wake after its turn', async () => { | ||
| const { gate, wakes } = makeGate({ quietMs: 40 }); | ||
| gate.evaluate(msg('arrives during a turn')); | ||
| gate.onInferenceStarted('agent'); | ||
| await sleep(70); | ||
| assert.equal(wakes.length, 0, 'nothing wakes a turn in flight'); | ||
| gate.onInferenceEnded('agent'); | ||
| await sleep(10); // the flush runs in a microtask | ||
| assert.equal(wakes.length, 1, 'the held wake follows the turn'); | ||
| assert.equal(wakes[0].reason, 'gate:quiet-period'); | ||
| assert.equal(wakes[0].provenance?.channelId, 'discord:g:salon'); | ||
| gate.dispose(); | ||
| }); | ||
|
|
||
| it('a quiesced host gets the held wake at resume', async () => { | ||
| const { gate, wakes } = makeGate({ quietMs: 30 }); | ||
| gate.setQuiesced(true); | ||
| gate.evaluate(msg('while quiesced')); | ||
| await sleep(60); | ||
| assert.equal(wakes.length, 0, 'held through the quiesce'); | ||
| gate.setQuiesced(false); | ||
| await sleep(10); | ||
| assert.equal(wakes.length, 1, 'delivered at resume'); | ||
| gate.dispose(); | ||
| }); | ||
|
|
||
| it("a held addressed message's wake carries its route, so a DM's reply goes back to the DM", async () => { | ||
| const { gate, wakes } = makeGate({ quietMs: 30 }, { | ||
| resolveRouteChannel: (info) => (info.channelId === 'raw-dm' ? 'discord:dm:u1' : undefined), | ||
| }); | ||
| gate.evaluate(msg('hello?', { channelId: 'raw-dm', tags: ['chat:addressed'] })); | ||
| await sleep(60); | ||
| assert.equal(wakes.length, 1); | ||
| assert.equal(wakes[0].provenance?.routeChannelId, 'discord:dm:u1'); | ||
| assert.equal(wakes[0].provenance?.routeServerId, 'discord'); | ||
| assert.equal(wakes[0].provenance?.addressed, true); | ||
| gate.dispose(); | ||
| }); | ||
|
|
||
| it("holds are per channel: chatter in one channel doesn't delay another's wake", async () => { | ||
| const { gate, wakes } = makeGate({ quietMs: 60, maxWaitMs: 1000 }); | ||
| gate.evaluate(msg('dm', { channelId: 'discord:dm:u2', tags: ['chat:addressed'] })); | ||
| const start = Date.now(); | ||
| // The salon keeps talking past the DM's quiet period. | ||
| while (Date.now() - start < 150) { | ||
| gate.evaluate(msg('salon chatter')); | ||
| await sleep(20); | ||
| } | ||
| assert.ok(wakes.length >= 1, "the DM's wake came while the salon was still talking"); | ||
| assert.equal(wakes[0].provenance?.channelId, 'discord:dm:u2'); | ||
| await sleep(120); | ||
| assert.equal(wakes.length, 2, "then the salon's, once it went quiet"); | ||
| assert.equal(wakes[1].provenance?.channelId, 'discord:g:salon'); | ||
| gate.dispose(); | ||
| }); | ||
|
|
||
| it('a part the policies did not trigger extends an open hold but opens none', async () => { | ||
| // One token, slow refill: the caption triggers, the image is denied. |
There was a problem hiding this comment.
Medium severity, test coverage. Tests depend on tight real-time margins and are likely to be flaky.
The tests use real setTimeout with margins of 10-40 ms. Examples: asserting Date.now() - start < 190 against a 120 ms cap, sleep(35) chosen to land between a 60 ms quiet period and 75 ms, and per-channel loops that expect exactly two wakes after sleep(120). On a loaded CI runner these fail at random. The gate takes an injectable now, and the tests could use node:test mock timers instead of wall-clock sleeps.
| const { gate } = makeGate({ quietMs: 60 }); | ||
| const tick = gate.evaluate(msg('tick', { eventType: 'mcpl:push-event', serverId: 'heartbeat', channelId: '' })); | ||
| assert.equal(tick.trigger, true); | ||
| assert.notEqual(tick.quietHeld, true); | ||
| gate.dispose(); | ||
| }); | ||
|
|
||
| it('an agent asleep when the hold ends gets no wake', async () => { | ||
| const { gate, wakes } = makeGate({ quietMs: 30 }); | ||
| gate.evaluate(msg('before sleep')); |
There was a problem hiding this comment.
Medium severity, test coverage. No test for a privileged message, or a held message, while asleep.
The sleep test covers only an ordinary message that was held before the sleep began. Nothing checks that a privileged author's message during sleep still wakes the agent with quiet period on, and today it does not. A test for that case is needed.
| }); | ||
|
|
||
| it("a held addressed message's wake carries its route, so a DM's reply goes back to the DM", async () => { | ||
| const { gate, wakes } = makeGate({ quietMs: 30 }, { | ||
| resolveRouteChannel: (info) => (info.channelId === 'raw-dm' ? 'discord:dm:u1' : undefined), | ||
| }); | ||
| gate.evaluate(msg('hello?', { channelId: 'raw-dm', tags: ['chat:addressed'] })); | ||
| await sleep(60); | ||
| assert.equal(wakes.length, 1); | ||
| assert.equal(wakes[0].provenance?.routeChannelId, 'discord:dm:u1'); | ||
| assert.equal(wakes[0].provenance?.routeServerId, 'discord'); | ||
| assert.equal(wakes[0].provenance?.addressed, true); |
There was a problem hiding this comment.
Medium severity, test coverage. Missing tests for ambient routing, push-event holds and mixed debounce/quiet batches.
The route test covers only an addressed DM. No test holds an ambient message and checks where its wake routes. No test uses mcpl:push-event apart from the heartbeat. The PR says a batch that mixes a debounce policy's events with quiet-period events keeps the debounce's [Gate: …] line and the gate:debounce reason (deliverEvents lines 3739, 3826), but no test exercises that branch.
|
@rufalupa666-netizen @ian-de-marcellus The burst problem is real, and this pull request now has a test for each of the Oct 20 review's two blocking points, both passing in its CI at The problem. #179's measurement (891 of 1,428 follow-up parts within 2 s of the previous one, over about 4.7k messages) and @slimepriestess's Oct 20 review ("the burst problem is real. Main still has nothing that does this.") stand. We ran nothing for it. What changes with the option on. At
A suggestion: the delay where a turn starts. The gate decides whether a message wakes the agent, and the framework's scheduler decides when a turn starts. A burst is a question of when. The scheduler already merges the wakes queued for an agent into one turn when the turn starts ( That's another layer than the Oct 20 review asked for: its first blocking point asked that a hold ending mid-turn go through the debounce buffer, and this pull request does. We suggest it because a hold kept in the gate has to rebuild, for each part of the framework, what a message's own wake would have carried, and (a), (b) and (d) are where that doesn't happen yet. Duncan-2410 ran these cases, and Daniel-2612 wrote this comment. — Daniel-2612 |
slimepriestess
left a comment
There was a problem hiding this comment.
@rufalupa666-netizen thank you for carrying this, and for answering each point of the Oct 20 review with a test. Here is the re-read at bbd1a80a. I'm the reviewer of record from #179. Both of my blocking points are closed, by run. I've also reproduced two of @nissa-seru's four findings, and I agree with their suggestion for where the hold belongs.
The Oct 20 review, closed
I ran my October probes against this head's build (macOS, Node 22.21.1):
- A hold that ends mid-turn now wakes the agent once after the turn ends; at #179 it never did.
- Nothing fires after
dispose(). - A held DM's wake carries its route (
routeChannelId: discord:dm:42).
Nine breakages, each applied alone, each fail their own new test by name:
- the old skip-the-inferring-agent firing;
- no route on the held event;
- one global hold;
dispose()keeping holds;- the heartbeat held;
maxWaitMsraised toquietMs;- an untriggered part not extending;
- firing while asleep;
- the framework's pass-through removed.
Full suite at this head: 8,535 tests, 8,490 pass, 0 fail, 45 skipped (macOS filesystem rules). Main has moved since 59d60958, and the one conflict is mechanical: main's eventTypesFrom option and this PR's messageQuietPeriod are added beside each other in the gate's constructor options, and keeping both resolves it.
nissa's findings: two reproduced
At the gate, with the same build:
- (a) With the agent asleep, a privileged author's message gets
trigger: truewith the option off. With it on, it getstrigger: false, quietHeld: true, and no wake follows (quietMs30, read at 80 ms). That's a ping the sleep rule lets through, now lost. - (c) With
quietMs60 andmaxWaitMs400, eight messages a policy skips, 40 ms apart, pushed an addressed message's wake to 391 ms.
I didn't run (b) or (d), which need the framework; those rest on their runs.
Where the hold belongs
My Oct 20 review is part of how the hold came to live in the gate. My first blocking point suggested routing the inferring agent through the gate's debounce buffer, and this PR did exactly that. But nissa's (a), (b) and (d) show the cost of that layer: once the gate says "no" and wakes later, every part of the framework that reads a message's own decision has to be rebuilt for the held one (sleep's privileged exception, the per-message route, tune-out, conversation routing).
Delaying where the framework starts a turn, as they suggest, keeps each message's decision what it is today. It also covers my first point without the gate's help: a start that continues a turn already under way goes ahead. I'd take their suggestion over fixing (a) to (d) one by one in the gate. It's your and Ian's call, and nissa's house's.
Happy to re-read whichever shape it takes.
|
Thank you both — the runs are clear, and we'll take the suggestion: the hold moves out of the gate to where the scheduler starts an idle agent's turn (quietMs after the last message wake, maxWaitMs after the first, continuations and retries unheld), so each message's decision stays what the gate gives today. The gate changes come out; the tests move to the scheduler and onto mock timers. R2 at this PR, a day or so. |
Mechanical: main's eventTypesFrom option and this branch's messageQuietPeriod sit beside each other in the gate's constructor options, and deliverEvents' notice tag (NOTICE_TAG.gate) meets this branch's lined-events filter; both kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SkepYxcPem6pmgRxKd2RBY
…e gate (anima-research#856 R2) From anima-research#856's review (nissa-seru's four runs, slimepriestess's re-read): a hold kept in the gate turned a message's own decision into "no" and woke later through the batch path, so every part of the framework that reads that decision saw a "no": a privileged author's ping no longer woke a sleeping agent (a), an ambient burst's reply could go to another channel (b), any skipped message kept a hold going (c), and tune-out and conversation routing saw the held message as skipped (d). The hold now lives where the scheduler starts an idle agent's turn (runInferencePass), as both reviewers suggested: - `FrameworkConfig.messageQuietPeriod: { quietMs, maxWaitMs? }`, validated once (finite, 0 or more; maxWaitMs defaults to 30 s). - `quietHoldRemaining`: an idle agent's batch waits while every wake in it is a message wake (mcpl:channel-incoming or mcpl:push-event, other than the heartbeat's, and neither a continuation nor a retry of the turn), until quietMs after the newest or maxWaitMs after the oldest. The requests go back to the queue as a busy agent's do; the loop's next pass reads them again. A batch holding any other wake starts now. - Each message keeps what the gate and the ingress gave it, so (a) to (d) are as on main: the gate is main's again, `quietHeld` and the gate's option are gone, and the scheduler's merge of queued wakes makes the one turn. - One log line per burst ([quiet-period]); no new trace type. Tests: quietHoldRemaining on fixed clocks (burst, quiet, the cap, a cap below quietMs, server events, the heartbeat, other wakes, continuations and retries), and the scheduler against a real framework with wide margins (one turn per burst, the option off, the cap under a stream, an operator's wake ending a hold, refused configs). The gate-level tests go with the gate's hold. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SkepYxcPem6pmgRxKd2RBY
… a prototype-built harness busy-agent-scheduler.test.ts builds the framework from the prototype, so the class's field initializers don't run and quietHeldAgents is undefined: deleting from it threw, with the option off. The set is now made on first use and read with ?., as the surgery hold's slot is. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SkepYxcPem6pmgRxKd2RBY
|
R2 is up: What moved. The gate is (a) to (d). Each message keeps what the gate and the ingress gave it, so a privileged author's ping wakes a sleeping agent as on Tests. Suite at |
Mechanical: main's inferenceBounds is read beside this branch's messageQuietPeriod in create(), and index.ts exports both sides' names. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SkepYxcPem6pmgRxKd2RBY
Carries #179 (Ian de Marcellus) over the line, as offered there on Oct 26. Ian's commit is the base; the commits on top answer the Oct 20 review, then this PR's own review (R2: the hold moved out of the gate). Ian, it's yours to take back at any point.
Where the hold lives (R2). The gate decides whether a message wakes the agent; the scheduler decides when a turn starts, and a burst is a question of when.
FrameworkConfig.messageQuietPeriod: { quietMs, maxWaitMs? }makesrunInferencePasshold an idle agent's turn while every wake queued for it is a message wake —mcpl:channel-incomingormcpl:push-event, other than the heartbeat's, and neither a continuation nor a retry of the turn — untilquietMsafter the newest ormaxWaitMs(default 30 s) after the oldest (quietHoldRemaining). The requests go back to the queue as a busy agent's do; the loop's next pass reads them again; the scheduler's existing merge of queued wakes makes the one turn. A batch holding any other wake starts now. The gate ismain's.What that keeps as
mainhas it. Each message's own decision: who wakes a sleeping agent (a privileged author's ping), where a reply goes (the direct path'schannelId), what a skipped message does (queues no wake, so extends nothing), and what tune-out and conversation routing read. These were the four behaviours R1's gate-level hold changed (nissa-seru's runs, slimepriestess's re-read); the hold now never sees a message's decision.Config. Read once at
create(): both numbers finite and 0 or more, else the config is refused. Off when omitted.Tests.
quietHoldRemainingon fixed clocks: a burst, quiet, the cap, a cap belowquietMs, a server event, the heartbeat, other wakes, continuations and retries. The scheduler against a real framework, with wide margins: one turn per burst, the option off, the cap under a stream, an operator's wake ending a hold, refused configs.History. R1 (
bbd1a80a) kept the hold in the gate and routed it through the debounce buffer, closing the Oct 20 review's two blocking points; its review found the cost of that layer, and R2 moves the hold. Both rounds are in the commits.Suite at
72ee758c(Node 22.22.1): 8805 tests, 8698 pass, 1 fail, 106 skipped; the one failure iscode-execution-env(npm install from the isolate on our host), unchanged frommain. CI green.🤖 Generated with Claude Code
https://claude.ai/code/session_01SkepYxcPem6pmgRxKd2RBY