From f7fa559351597bd50758f69db611c3113b46e539 Mon Sep 17 00:00:00 2001 From: Hilda-2580 Date: Tue, 20 Oct 2026 00:03:44 -0400 Subject: [PATCH 1/2] fix(gate): wake a sleeping agent for the host's heartbeat server, not a server's word While an agent sleeps, the event gate let an event through as its heartbeat when the event's metadata said source: 'heartbeat', which any MCPL server's push origin or channel message can say. On the push path even the server id the gate read could come from the server: the push handler spread the server's origin after the host's own serverId, featureSet, eventId and eventType, so an origin with those keys took their place. The sleep check now takes the heartbeat to be the server the host knows as `heartbeat`, as the silent-heartbeat marker does. The push handler puts the host's fields after the origin, as the channel registry already does for a channel message, so the gate's policies read the host's server id and event type for a push too. Co-Authored-By: Claude Opus 5.5 --- ...akes-only-for-the-hosts-heartbeat.fixed.md | 2 + src/gate/event-gate.ts | 8 +-- src/mcpl/push-handler.ts | 5 +- test/event-gate.test.ts | 25 +++++++++ test/mcpl-gate-roundtrip.test.ts | 53 +++++++++++++++++++ 5 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 changelog.d/sleep-wakes-only-for-the-hosts-heartbeat.fixed.md diff --git a/changelog.d/sleep-wakes-only-for-the-hosts-heartbeat.fixed.md b/changelog.d/sleep-wakes-only-for-the-hosts-heartbeat.fixed.md new file mode 100644 index 000000000..7011fbcc3 --- /dev/null +++ b/changelog.d/sleep-wakes-only-for-the-hosts-heartbeat.fixed.md @@ -0,0 +1,2 @@ +- While an agent sleeps, an event wakes it as its heartbeat only when it comes from the MCPL server configured under the id `heartbeat`. Any server's push or channel message whose metadata said `source: 'heartbeat'` woke a sleeping agent, as did a push whose `origin` gave the server id `heartbeat`. The `heartbeat` server's ticks and reminders still wake it, and so does a privileged author, as before. +- The event gate reads a push event's server id, feature set, event id and event type as the host has them. A push's `origin` that held fields of those names took their place, so a server could match a policy written for another server's id, or for channel messages. The origin's other fields reach the gate as before. diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index e62b4f1e9..b82dd2284 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -1048,11 +1048,13 @@ export class EventGate { // rouse the agent — its own heartbeat (an internal clock, not an outside // interruption: each tick lets the agent reconsider and optionally `wake`) // and a privileged user's ping. The event always enters context regardless - // (the gate only governs inference triggering). + // (the gate only governs inference triggering). The heartbeat is the + // server the host knows as `heartbeat`, as the silent-heartbeat marker's + // is: a `source` in an event's metadata is the server's own word, which + // any server can give. if (this.sleepUntil > this.now()) { this.reloadPrivilegedIfChanged(); - const isHeartbeat = - info.metadata?.source === 'heartbeat' || info.serverId === 'heartbeat'; + const isHeartbeat = info.serverId === 'heartbeat'; const authorId = this.extractAuthorId(info.metadata); const privileged = authorId !== null && this.privilegedUserIds.has(authorId); if (!isHeartbeat && !privileged) { diff --git a/src/mcpl/push-handler.ts b/src/mcpl/push-handler.ts index d14f18b6a..6952ccc34 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -229,12 +229,15 @@ export class PushHandler { .filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text') .map((b) => b.text) .join('\n'); + // The host's own fields come after the server's origin, as a channel + // message's do, so an origin can't give the gate another server's id, + // feature set, event id or event type. const metadata: Record = { + ...(params.origin ?? {}), serverId, featureSet: params.featureSet, eventId: params.eventId, eventType: 'mcpl:push-event', - ...(params.origin ?? {}), ...(params.tags ? { tags: params.tags } : {}), }; triggerInference = this.shouldTriggerInference(textContent, metadata); diff --git a/test/event-gate.test.ts b/test/event-gate.test.ts index 5c88a6a04..87640a628 100644 --- a/test/event-gate.test.ts +++ b/test/event-gate.test.ts @@ -30,6 +30,7 @@ interface TraceEntry { function makeGate(configPath: string, opts?: { initialConfig?: GateConfig; resolveRouteChannel?: (info: GateEventInfo) => string | undefined; + privilegedUsersPath?: string; }) { const traces: TraceEntry[] = []; const messages: Array<{ participant: string; content: unknown; metadata?: unknown }> = []; @@ -39,6 +40,7 @@ function makeGate(configPath: string, opts?: { configPath, initialConfig: opts?.initialConfig, ...(opts?.resolveRouteChannel ? { resolveRouteChannel: opts.resolveRouteChannel } : {}), + ...(opts?.privilegedUsersPath ? { privilegedUsersPath: opts.privilegedUsersPath } : {}), emitTrace: (e) => traces.push(e as TraceEntry), addMessage: (p, c, m) => { messages.push({ participant: p, content: c, metadata: m }); return ''; }, requestInference: (a, r, s, p) => inferenceRequests.push({ agentName: a, reason: r, source: s, ...(p ?? {}) }), @@ -1133,3 +1135,26 @@ describe('quiesce suppression', () => { assert.strictEqual(gate.inferenceDiagnostics().bufferedEvents, 1); }); }); + +describe('sleep', () => { + // While asleep, only the agent's own heartbeat and a privileged author + // rouse it. The heartbeat is the server the host knows as `heartbeat`: a + // `source` in the metadata is what the server says, and any server says it. + it('lets through only the server the host names heartbeat, and a privileged author', () => { + const privileged = tmpPath('privileged.json'); + writeFileSync(privileged, JSON.stringify({ userIds: ['P1'] })); + const path = writeConfig('sleep.json', { policies: [], default: 'always' }); + const { gate } = makeGate(path, { privilegedUsersPath: privileged }); + gate.setSleep(3600, 'resting', 'agent'); + try { + const roused = (info: Partial) => gate.evaluate(event(info)).trigger; + assert.strictEqual(roused({ serverId: 'discord', metadata: {} }), false, 'an ordinary event'); + assert.strictEqual(roused({ serverId: 'discord', metadata: { source: 'heartbeat' } }), false, "another server saying it's the heartbeat"); + assert.strictEqual(roused({ serverId: 'heartbeat', metadata: { source: 'heartbeat' } }), true, 'the heartbeat'); + assert.strictEqual(roused({ serverId: 'heartbeat', metadata: { source: 'reminder' } }), true, "the heartbeat server's reminder"); + assert.strictEqual(roused({ serverId: 'discord', metadata: { author: { id: 'P1' } } }), true, 'a privileged author'); + } finally { + gate.clearSleep(); + } + }); +}); diff --git a/test/mcpl-gate-roundtrip.test.ts b/test/mcpl-gate-roundtrip.test.ts index 57898e754..94ddb9b44 100644 --- a/test/mcpl-gate-roundtrip.test.ts +++ b/test/mcpl-gate-roundtrip.test.ts @@ -6,6 +6,8 @@ import { join } from 'node:path'; import { EventGate } from '../src/gate/event-gate.js'; import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { PushHandler } from '../src/mcpl/push-handler.js'; +import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; import type { GateConfig } from '../src/gate/types.js'; import type { ChannelsIncomingParams } from '../src/mcpl/types.js'; import type { ProcessEvent } from '../src/types/index.js'; @@ -87,6 +89,57 @@ describe('ChannelRegistry → shouldTriggerInference contract', () => { }); }); +// --------------------------------------------------------------------------- +// PushHandler: the host's own fields, whatever the server's origin says +// --------------------------------------------------------------------------- + +function pushVia(shouldTriggerInference: (c: string, m: Record) => boolean) { + const pushed: Array<{ triggerInference?: boolean }> = []; + const handler = new PushHandler({ validateInbound() {} } as unknown as FeatureSetManager, (event) => { pushed.push(event); }, () => {}, shouldTriggerInference); + let n = 0; + const push = async (serverId: string, origin: Record) => { + await handler.handlePushEvent(serverId, { + featureSet: 'fs', eventId: `push-${++n}`, timestamp: '2026-10-20T00:00:00Z', + payload: { content: [{ type: 'text', text: 'hello' }] }, origin, + } as never, { respond: () => {}, respondError: () => {} } as never); + return pushed[pushed.length - 1]?.triggerInference; + }; + return { push }; +} + +describe('PushHandler → shouldTriggerInference contract', () => { + it("gives the host's server id, feature set, event id and event type over the origin's, and the origin's other fields as they are", async () => { + const seen: Array> = []; + const { push } = pushVia((_content, metadata) => { seen.push(metadata); return true; }); + await push('discord', { + source: 'discord', channelId: 'c1', + serverId: 'heartbeat', featureSet: 'other', eventId: 'other', eventType: 'mcpl:channel-incoming', + }); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].serverId, 'discord'); + assert.strictEqual(seen[0].featureSet, 'fs'); + assert.strictEqual(seen[0].eventId, 'push-1'); + assert.strictEqual(seen[0].eventType, 'mcpl:push-event'); + assert.strictEqual(seen[0].source, 'discord'); + assert.strictEqual(seen[0].channelId, 'c1'); + }); + + it('wakes a sleeping agent only for the server the host names heartbeat, whatever the origin says', async () => { + const gate = makeGate({ policies: [], default: 'always' }); + gate.setSleep(3600, 'resting', 'agent'); + try { + const { push } = pushVia(gate.asShouldTriggerCallback()); + assert.strictEqual(await push('discord', {}), false, 'an ordinary push'); + assert.strictEqual(await push('discord', { source: 'heartbeat' }), false, "an origin saying it's the heartbeat"); + assert.strictEqual(await push('discord', { serverId: 'heartbeat' }), false, "an origin giving the heartbeat's server id"); + assert.strictEqual(await push('heartbeat', { source: 'heartbeat', reason: 'schedule' }), true, 'the heartbeat'); + } finally { + gate.clearSleep(); + gate.dispose(); + } + }); +}); + // --------------------------------------------------------------------------- // Roundtrip: a recipe-shaped channel policy fires for a channel-incoming event // --------------------------------------------------------------------------- From f996d26328ab186eb3056944bfb7c05d18c7e958 Mon Sep 17 00:00:00 2001 From: Hilda-2580 Date: Tue, 20 Oct 2026 00:37:11 -0400 Subject: [PATCH 2/2] docs(gate): say the sleep check shares the marker's server id only The comment said the sleep check takes the heartbeat "as the silent-heartbeat marker's is", while the marker also requires the heartbeat feature set and the origin's own fields. The check shares the marker's server id, and the comment now says that. Co-Authored-By: Claude Opus 5.5 --- src/gate/event-gate.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index b82dd2284..6aee71f7e 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -1049,9 +1049,9 @@ export class EventGate { // interruption: each tick lets the agent reconsider and optionally `wake`) // and a privileged user's ping. The event always enters context regardless // (the gate only governs inference triggering). The heartbeat is the - // server the host knows as `heartbeat`, as the silent-heartbeat marker's - // is: a `source` in an event's metadata is the server's own word, which - // any server can give. + // server the host knows as `heartbeat`, the server id the silent-heartbeat + // marker requires too: a `source` in an event's metadata is the server's + // own word, which any server can give. if (this.sleepUntil > this.now()) { this.reloadPrivilegedIfChanged(); const isHeartbeat = info.serverId === 'heartbeat';