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..6aee71f7e 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`, 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.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 // ---------------------------------------------------------------------------