Skip to content

feat(gate): messageQuietPeriod — one wake per message burst - #179

Open
ian-de-marcellus wants to merge 1 commit into
anima-research:mainfrom
ian-de-marcellus:feat/gate-message-quiet-period
Open

ian-de-marcellus wants to merge 1 commit into
anima-research:mainfrom
ian-de-marcellus:feat/gate-message-quiet-period

Conversation

@ian-de-marcellus

Copy link
Copy Markdown
Contributor

Problem. Discord delivers one logical message as several: a caption followed by forwarded images, or a long bot reply split into 2000-character parts arriving 0–2 s apart. Each part is a separate MCPL channel-incoming event, and each triggers a wake. The agent replies to the caption before the image arrives, then again. In a room with several agents, multi-part bot messages set off cascades of partial replies. Over four weeks of one host's traffic (~4.7k messages), 891 of 1,428 follow-up parts arrived within 2 s of the previous one.

Fix. Opt-in GateOptions.messageQuietPeriod: { quietMs, maxWaitMs? }. When a policy would trigger on a message-driven event (mcpl:channel-incoming / mcpl:push-event), the gate holds the wake until no further message has arrived for quietMs (capped at maxWaitMs, default 30 s, from the first held message), then wakes once with the combined provenance. Messages still enter context at arrival; only the wake waits. Agents already mid-turn are not re-woken, since they received the messages as mid-turn input. Timers, tool results, and heartbeats are unaffected. It's implemented gate-wide rather than as a policy because gate policies are first-match-wins, so a debounce policy would displace existing rules.

Tests. test/event-gate-quiet-period.test.ts: a burst wakes once after the senders go quiet; the hold is capped at maxWaitMs under a continuous stream; an agent already mid-turn is not re-woken; non-message wakes are not delayed, and the option is off by default. Full suite: 958 pass / 0 fail.

🤖 Generated with Claude Code

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>
@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 0/5 Tier: apex

[Medium risk] Adds message batching to the event gate.

This PR is not safe to merge until deferred wakes preserve message routing and the gate's lifecycle and suppression behavior.

Findings

  1. P1 Wake precedes message storage ▶
  2. P1 Quiet wake bypasses fork routing ▶
  3. P1 Quiet wake loses reply channel ▶
  4. P1 Quiet hold bypasses tune-out ▶
  5. P1 Quiet wake interrupts sleep ▶
  6. P1 Mid-turn messages lose wakes ▶
  7. P1 Filtered parts shorten quiet window ▶
  8. P1 Heartbeats are delayed ▶
  9. P1 Hard cap exceeds configuration ▶
  10. P2 Timer survives gate disposal ▶
Fix with agent prompt
### Issue 1
src/gate/event-gate.ts:1087
When an earlier queued event takes longer than `quietMs` to process, this timer can expire before the framework processes and stores the incoming message. The resulting inference can begin without the caption or attachments it was meant to wait for, producing a partial reply.

### Issue 2
src/gate/event-gate.ts:1485
When a message belongs to a channel-bound conversation fork, the ordinary path stores it for and wakes only that fork. This timer instead wakes every non-subconscious agent, including unrelated forks and the dormant template, even though their contexts did not receive the message.

### Issue 3
src/gate/event-gate.ts:1489
For a held Discord DM or messages arriving across channels, this request carries the chosen channel only as wake telemetry, not as the turn's routing channel. The trunk turn therefore falls back to the global channel, so its reply can go to the wrong room or have no usable destination.

### Issue 4
src/gate/event-gate.ts:1088
For an addressed message in a tuned-out channel, changing the approved gate verdict to `trigger: false` makes tune-out treat the message as suppressed. It misses the acknowledgement and subconscious wake, but the timer later wakes the resident directly even though the message was diverted from its compiled view.

### Issue 5
src/gate/event-gate.ts:1488-1489
If the agent calls `sleep` after a message is held but before this timer expires, the held timer remains active. Its callback requests an inference without checking the sleep window, waking the agent for an external message while it is supposed to remain asleep.

### Issue 6
src/gate/event-gate.ts:1486-1488
A message received during a turn can remain deferred until that turn ends. If this timer fires first, the `inferring` check discards its wake; teardown then stores the message without requesting another turn, so it waits for an unrelated wake. If the message was instead injected at a tool boundary and the turn ends before the timer fires, this check permits a redundant turn.

### Issue 7
src/gate/event-gate.ts:1083
With a rate-limited policy, a caption can start the hold while subsequent image messages are stored but denied a trigger. Because only triggering messages reset this timer, the agent can wake while those later parts of the same burst are still arriving.

### Issue 8
src/gate/event-gate.ts:1085
`mcpl:push-event` can carry a heartbeat rather than a message; the gate itself models heartbeats with this event type. When its policy approves such an event, this condition holds its wake for `quietMs`, contrary to the promise that heartbeats remain immediate.

### Issue 9
src/gate/event-gate.ts:623
If a caller sets `{ quietMs: 10000, maxWaitMs: 2000 }`, this clamp raises the configured hard cap to 10 seconds. The first message can therefore wait 10 seconds instead of the documented maximum of two.

```suggestion
    this.quietMaxWaitMs = Math.max(0, opts.messageQuietPeriod?.maxWaitMs ?? 30_000);
```

### Issue 10
src/gate/event-gate.ts:1475
If the framework stops during a quiet hold, `EventGate.dispose()` does not clear this timer. It can later enqueue an inference request into the stopped framework, leaving a stale wake behind.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR adds an opt-in quiet-period timer for policy-approved MCPL wakes, forwards its configuration through the framework, and adds gate-level tests.

  • The deferred callback bypasses message routing and later gate constraints, while its timing can lose, duplicate or prematurely issue wakes.
  • The new tests exercise timer behavior but not the framework ingestion and inference paths involved in those failures.

Reviews (1) · Last reviewed commit: "feat(gate): messageQuietPeriod — one wak..."

Comment thread src/gate/event-gate.ts
this.quietMs > 0 &&
(info.eventType === 'mcpl:channel-incoming' || info.eventType === 'mcpl:push-event')
) {
this.holdForQuietPeriod(info);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Wake precedes message storage When an earlier queued event takes longer than quietMs to process, this timer can expire before the framework processes and stores the incoming message. The resulting inference can begin without the caption or attachments it was meant to wait for, producing a partial reply.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1087

Comment:
**Wake precedes message storage** When an earlier queued event takes longer than `quietMs` to process, this timer can expire before the framework processes and stores the incoming message. The resulting inference can begin without the caption or attachments it was meant to wait for, producing a partial reply.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/gate/event-gate.ts
this.quietEvents = [];
if (events.length === 0) return;
const provenance = wakeProvenance(events);
for (const agentName of this.getAgentNamesFn()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Quiet wake bypasses fork routing When a message belongs to a channel-bound conversation fork, the ordinary path stores it for and wakes only that fork. This timer instead wakes every non-subconscious agent, including unrelated forks and the dormant template, even though their contexts did not receive the message.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1485

Comment:
**Quiet wake bypasses fork routing** When a message belongs to a channel-bound conversation fork, the ordinary path stores it for and wakes only that fork. This timer instead wakes every non-subconscious agent, including unrelated forks and the dormant template, even though their contexts did not receive the message.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/gate/event-gate.ts
// An agent already mid-turn received these messages as mid-turn input;
// waking it again afterwards would spend a redundant turn.
if (this.inferring.has(agentName)) continue;
this.requestInferenceFn(agentName, 'gate:quiet-period', 'gate', provenance);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Quiet wake loses reply channel For a held Discord DM or messages arriving across channels, this request carries the chosen channel only as wake telemetry, not as the turn's routing channel. The trunk turn therefore falls back to the global channel, so its reply can go to the wrong room or have no usable destination.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1489

Comment:
**Quiet wake loses reply channel** For a held Discord DM or messages arriving across channels, this request carries the chosen channel only as wake telemetry, not as the turn's routing channel. The trunk turn therefore falls back to the global channel, so its reply can go to the wrong room or have no usable destination.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/gate/event-gate.ts
(info.eventType === 'mcpl:channel-incoming' || info.eventType === 'mcpl:push-event')
) {
this.holdForQuietPeriod(info);
decision = { ...decision, trigger: false, quietHeld: 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.

P1 Quiet hold bypasses tune-out For an addressed message in a tuned-out channel, changing the approved gate verdict to trigger: false makes tune-out treat the message as suppressed. It misses the acknowledgement and subconscious wake, but the timer later wakes the resident directly even though the message was diverted from its compiled view.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1088

Comment:
**Quiet hold bypasses tune-out** For an addressed message in a tuned-out channel, changing the approved gate verdict to `trigger: false` makes tune-out treat the message as suppressed. It misses the acknowledgement and subconscious wake, but the timer later wakes the resident directly even though the message was diverted from its compiled view.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/gate/event-gate.ts
Comment on lines +1488 to +1489
if (this.inferring.has(agentName)) continue;
this.requestInferenceFn(agentName, 'gate:quiet-period', 'gate', provenance);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Quiet wake interrupts sleep If the agent calls sleep after a message is held but before this timer expires, the held timer remains active. Its callback requests an inference without checking the sleep window, waking the agent for an external message while it is supposed to remain asleep.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1488-1489

Comment:
**Quiet wake interrupts sleep** If the agent calls `sleep` after a message is held but before this timer expires, the held timer remains active. Its callback requests an inference without checking the sleep window, waking the agent for an external message while it is supposed to remain asleep.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/gate/event-gate.ts
Comment on lines +1486 to +1488
// An agent already mid-turn received these messages as mid-turn input;
// waking it again afterwards would spend a redundant turn.
if (this.inferring.has(agentName)) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Mid-turn messages lose wakes A message received during a turn can remain deferred until that turn ends. If this timer fires first, the inferring check discards its wake; teardown then stores the message without requesting another turn, so it waits for an unrelated wake. If the message was instead injected at a tool boundary and the turn ends before the timer fires, this check permits a redundant turn.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1486-1488

Comment:
**Mid-turn messages lose wakes** A message received during a turn can remain deferred until that turn ends. If this timer fires first, the `inferring` check discards its wake; teardown then stores the message without requesting another turn, so it waits for an unrelated wake. If the message was instead injected at a tool boundary and the turn ends before the timer fires, this check permits a redundant turn.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/gate/event-gate.ts
}
if (observed.length > 0) decision.observed = observed;
if (
decision.trigger &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Filtered parts shorten quiet window With a rate-limited policy, a caption can start the hold while subsequent image messages are stored but denied a trigger. Because only triggering messages reset this timer, the agent can wake while those later parts of the same burst are still arriving.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1083

Comment:
**Filtered parts shorten quiet window** With a rate-limited policy, a caption can start the hold while subsequent image messages are stored but denied a trigger. Because only triggering messages reset this timer, the agent can wake while those later parts of the same burst are still arriving.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/gate/event-gate.ts
if (
decision.trigger &&
this.quietMs > 0 &&
(info.eventType === 'mcpl:channel-incoming' || info.eventType === 'mcpl:push-event')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Heartbeats are delayed mcpl:push-event can carry a heartbeat rather than a message; the gate itself models heartbeats with this event type. When its policy approves such an event, this condition holds its wake for quietMs, contrary to the promise that heartbeats remain immediate.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1085

Comment:
**Heartbeats are delayed** `mcpl:push-event` can carry a heartbeat rather than a message; the gate itself models heartbeats with this event type. When its policy approves such an event, this condition holds its wake for `quietMs`, contrary to the promise that heartbeats remain immediate.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/gate/event-gate.ts
messageQuietPeriod?: { quietMs: number; maxWaitMs?: number };
}) {
this.quietMs = Math.max(0, opts.messageQuietPeriod?.quietMs ?? 0);
this.quietMaxWaitMs = Math.max(this.quietMs, 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.

P1 Hard cap exceeds configuration If a caller sets { quietMs: 10000, maxWaitMs: 2000 }, this clamp raises the configured hard cap to 10 seconds. The first message can therefore wait 10 seconds instead of the documented maximum of two.

Suggested change
this.quietMaxWaitMs = Math.max(this.quietMs, opts.messageQuietPeriod?.maxWaitMs ?? 30_000);
this.quietMaxWaitMs = Math.max(0, opts.messageQuietPeriod?.maxWaitMs ?? 30_000);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 623

Comment:
**Hard cap exceeds configuration** If a caller sets `{ quietMs: 10000, maxWaitMs: 2000 }`, this clamp raises the configured hard cap to 10 seconds. The first message can therefore wait 10 seconds instead of the documented maximum of two.

```suggestion
    this.quietMaxWaitMs = Math.max(0, opts.messageQuietPeriod?.maxWaitMs ?? 30_000);
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/gate/event-gate.ts
});
if (this.quietTimer) clearTimeout(this.quietTimer);
const wait = Math.max(0, Math.min(this.quietMs, this.quietFirstAt + this.quietMaxWaitMs - now));
this.quietTimer = setTimeout(() => this.fireQuietPeriod(), wait);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Timer survives gate disposal If the framework stops during a quiet hold, EventGate.dispose() does not clear this timer. It can later enqueue an inference request into the stopped framework, leaving a stale wake behind.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1475

Comment:
**Timer survives gate disposal** If the framework stops during a quiet hold, `EventGate.dispose()` does not clear this timer. It can later enqueue an inference request into the stopped framework, leaving a stale wake behind.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

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