Skip to content

feat(scheduler): messageQuietPeriod — one turn per message burst (carries #179) - #856

Open
rufalupa666-netizen wants to merge 6 commits into
anima-research:mainfrom
rufalupa666-netizen:feat/message-quiet-period
Open

rufalupa666-netizen wants to merge 6 commits into
anima-research:mainfrom
rufalupa666-netizen:feat/message-quiet-period

Conversation

@rufalupa666-netizen

@rufalupa666-netizen rufalupa666-netizen commented Oct 27, 2026 •

Copy link
Copy Markdown

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? } makes runInferencePass hold an idle agent's turn while every wake queued for 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 (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 is main's.

What that keeps as main has it. Each message's own decision: who wakes a sleeping agent (a privileged author's ping), where a reply goes (the direct path's channelId), 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. quietHoldRemaining on fixed clocks: a burst, quiet, the cap, a cap below quietMs, 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 is code-execution-env (npm install from the isolate on our host), unchanged from main. CI green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SkepYxcPem6pmgRxKd2RBY

ian-de-marcellus and others added 2 commits October 27, 2026 20:44
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

@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: 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, adds test/event-gate-quiet-period.test.ts, and reports 8533 tests with 8433 passing, 1 failing (also failing on main) 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 debounce policies, 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 as main handles 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:

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'. wakeProvenance sets routeChannelId only 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, the gate:decision trace reports trigger: false under the matched policy name and has no quietHeld field. 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. Add quietHeld to the trace.
  • Low severity, maintainability, src/gate/event-gate.ts:3585-3589. A no-op placeholder timer is created and then cleared at once. holdForQuietPeriod stores setTimeout(() => {}, 0) only so that armQuietHold can 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. holdForQuietPeriod repeats the ~20-line PendingEvent literal from handleDebounce (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 shared pendingEventFor(policyName, info, inContext) helper. As written, it also calls routeChannelFor twice per held message: once in quietKeyFor and 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)'. fireQuietHold actually calls fireBatch directly and skips fireDebounce's judge branch. The comment should name fireBatch.

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
Comment on lines +3604 to +3622
* 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread src/gate/event-gate.ts Outdated
Comment on lines +3141 to +3143
} else if (this.quietHolds.has(key)) {
this.armQuietHold(key);
}

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. 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.

Comment thread src/gate/event-gate.ts Outdated
Comment on lines +2169 to +2172
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);

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. 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.

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

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. 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.

Comment thread src/gate/event-gate.ts Outdated
Comment on lines +3138 to +3141
if (decision.trigger) {
this.holdForQuietPeriod(key, info);
decision = { ...decision, trigger: false, quietHeld: true };
} else if (this.quietHolds.has(key)) {

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 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.

Comment thread test/event-gate-quiet-period.test.ts Outdated
Comment on lines +79 to +158

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.

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. 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.

Comment thread test/event-gate-quiet-period.test.ts Outdated
Comment on lines +187 to +196
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'));

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. 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.

Comment thread test/event-gate-quiet-period.test.ts Outdated
Comment on lines +125 to +136
});

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);

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. 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.

@nissa-seru

Copy link
Copy Markdown
Contributor

@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 bbd1a80a. With messageQuietPeriod on, though, four other behaviours change, each shown below by a run. Three of the four come from the hold turning a message's own decision into "no" and waking later through the batch path, so we suggest holding where the framework starts a turn instead.

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 bbd1a80a, a message that would wake the agent gets the decision { trigger: false, quietHeld: true } (src/gate/event-gate.ts:3140). When the hold ends, fireQuietHold (:3605) wakes the agent through fireBatch (:3624). We ran the cases below on Linux with Bun 1.4.4 running the TypeScript sources. The framework runs used @animalabs/membrane 0.8.0 and @animalabs/context-manager 0.23.1. Cases (a) to (c) ran against the gate, built as test/event-gate-quiet-period.test.ts builds it: (a) with the option off and on, and (b) and (c) with it on. Cases (b) and (d) also ran through a real AgentFramework with the repository's MockMembrane and its MCPL test servers, at this head and at 59d60958.

  • (a) A privileged author's message no longer wakes a sleeping agent. With the agent asleep and the author on the privileged-users list, the gate's decision with the option off is trigger: true, since the sleep check at :3063 lets a privileged author through. With it on, the decision is trigger: false, quietHeld: true, and 80 ms later, with quietMs 30, the gate had requested no wake. :3610 drops a hold that ends while the agent sleeps. So the description's "A hold that ends while the agent sleeps is dropped and counted as suppressed, as the messages' own wakes would have been" doesn't hold for a privileged author, whose own message, with the option off, got the decision trigger: true while the agent slept. RevEngine's high-severity finding says the same.
  • (b) A reply can go to another channel. At the gate, a held ambient message's wake carried routeChannelId: null, and a held addressed message's carried its channel. Through the framework, an ambient message came in #room, and 150 ms later an ambient message came in #general, which a policy skips. With the option off, at this head and at 59d60958, the agent took one turn, in #room, and its reply went to #room. With it on (quietMs 1000), the turn was in #general, and the reply went to #general. We ran only this case, with a second channel's message during the hold. RevEngine's finding on the held route says the same.
  • (c) Messages the policies skip keep an open hold going. With quietMs 60 and maxWaitMs 400, an addressed message came, then eight bot messages 40 ms apart in the same channel, each skipped by a policy. The addressed message's wake came at 398 ms, against quietMs 60 and maxWaitMs 400. The description gives the extension for "A part the policies didn't trigger (a rate-limited image after its caption)", and :3141 extends an open hold for any message in its channel the policies didn't trigger. RevEngine's medium-severity finding "Any skipped message, not just a rate-limited part of the burst, extends an open hold" says the same.
  • (d) Tune-out and conversation routing see a held message as "no". The description leaves these "as main has them for a debounce batch", and the Oct 20 review raised them by reading. Through the framework:
    • Tune-out: the resident had tuned out a channel, and an addressed message came in it. With the option off, at this head and at 59d60958, the message got tune-out's acknowledgement (channels/acknowledge with the intent suppressed-tuned-out), and the Subconscious took one turn. With it on, there was no acknowledgement and no Subconscious turn, and the resident took one turn, in the tuned-out channel.
    • Conversation routing (FrameworkConfig.conversations, set to bind and trigger on every channel message): with the option off, a message in #room gave one turn, by the channel's fork. With it on, it gave two, the trunk's and the fork's.
    • Per-policy debounce, as the Oct 20 review puts it, "needs a policy match". :3132–3140 hold every channel message or push event whose decision is trigger: true, apart from the heartbeat's. So what the description leaves "as main has them for a debounce batch" reaches, with this option, every channel message or push event that would wake the agent, not only the ones a debounce policy matches.

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 (runInferencePass, src/framework.ts:16538 at 59d60958), and test/batched-wake-line.test.ts's "four wakes merged into one turn" pins that with one provider call. Our suggestion is to hold an idle agent's turn start while message wakes for it keep arriving, up to quietMs after the last and maxWaitMs after the first, then start one turn with everything queued. We think that gives one turn per burst while each message's decision stays what the gate gives today, so sleep and privileged authors, each message's route, tune-out and conversation routing see what they see on main. A start that continues a turn already under way, or retries a failed one, would go ahead without waiting.

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 slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@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;
  • maxWaitMs raised to quietMs;
  • 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: true with the option off. With it on, it gets trigger: false, quietHeld: true, and no wake follows (quietMs 30, read at 80 ms). That's a ping the sleep rule lets through, now lost.
  • (c) With quietMs 60 and maxWaitMs 400, 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.

@rufalupa666-netizen

Copy link
Copy Markdown
Author

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.

rufalupa666-netizen and others added 3 commits October 28, 2026 15:30
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
@rufalupa666-netizen

Copy link
Copy Markdown
Author

R2 is up: 0f0292f8 merges main (e85c57db), 2195f772 moves the hold to where the scheduler starts an idle agent's turn, and 72ee758c is a one-line follow-up for prototype-built harnesses.

What moved. The gate is main's again: no quietHeld, no gate option. FrameworkConfig.messageQuietPeriod: { quietMs, maxWaitMs? } is read once (finite, 0 or more; maxWaitMs defaults to 30 s). In runInferencePass, after the busy requeue, 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 (quietHoldRemaining, exported). The requests go back to the queue as a busy agent's do and the loop's next pass reads them again; the merge at the start makes the one turn. A batch holding any other wake starts now.

(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 main, a held wake's route is the direct path's channelId, a skipped message queues no wake and so extends nothing, and tune-out and conversation routing read the same verdicts they read today. The hold never sees a message's decision, only whether a queued wake is a message's.

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 went with the gate's hold.

Suite at 72ee758c (Node 22.22.1): 8805 tests, 8698 pass, 1 fail, 106 skipped; the one failure is code-execution-env (npm install from the isolate on our host), unchanged from main. CI green.

@rufalupa666-netizen rufalupa666-netizen changed the title feat(gate): messageQuietPeriod — one wake per message burst (carries #179) feat(scheduler): messageQuietPeriod — one turn per message burst (carries #179) Oct 28, 2026
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
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.

4 participants