Skip to content

fix(gate): wake a sleeping agent for the host's heartbeat server, not a server's word - #617

Merged
nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/sleep-heartbeat-is-the-hosts
Oct 20, 2026
Merged

nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/sleep-heartbeat-is-the-hosts

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

While an agent sleeps, the event gate now takes an event for its heartbeat only when the host received it from the MCPL server configured as heartbeat. A server's origin and metadata are its own claims (SPEC §16.6), so neither a source there nor an origin key named like one of the host's fields stands in for what the host knows: which server sent the event, and what kind of event it is.

Problem

  • The guarantee: the sleep tool tells the agent that while it sleeps, external pings and wakes are suppressed: "Your heartbeat still beats: you'll briefly rouse on each tick … Privileged users can also still reach you."
  • How it's meant to be used: the operator configures each MCPL server under an id in the host's configuration. heartbeat-mcpl's README and recipe configure the heartbeat as the server heartbeat, and the framework's silent-heartbeat marker matches on that server id, so that "arbitrary servers cannot claim the silent path".
  • Why the code makes the guarantee: the gate's sleep check enforces it, and its comment names "its own heartbeat (an internal clock…)".

The sleep check took an event for the heartbeat when its metadata said source: 'heartbeat'. A server can put any source in a push's origin or a channel message's metadata, so that was a server's claim standing in for the host's knowledge. On a push, the server id the gate read was open to the same claim: the push handler built the gate's metadata with the server's origin spread after the host's serverId, featureSet, eventId and eventType, so an origin with those keys took their place, in the sleep check and in policy matching by source and scope alike. A channel message's gate metadata already puts the host's fields after the message's own.

Changes

  • The sleep check takes the heartbeat to be the server the host knows as heartbeat (info.serverId === 'heartbeat'), the server id the silent-heartbeat marker also requires, and reads nothing from the metadata for it. heartbeat-mcpl's recipe wake policy already matches that id, since a policy's source is the server id. The heartbeat server's reminders still wake the agent, and so does a privileged author, as before.
  • The push handler puts the host's serverId, featureSet, eventId and eventType after the push's origin, as the channel registry already does for a channel message, so the host's own fields win. The origin's other fields reach the gate as before.

What each part is for

  • The sleep check's one condition makes "your heartbeat still beats" mean the host's heartbeat server. Keeping metadata.source beside it would leave the check open to any server.
  • The push handler's order makes info.serverId the host's on both paths. Without it, the sleep check's condition could still be met by an origin saying serverId: 'heartbeat'. It costs nothing to keep: the four fields move after the spread, with a comment.
  • The tests pin the events whose treatment changes, and the heartbeat's own wake, so neither direction can drift.

Tests

These ran on Linux with Node 24.22.0, at f7fa5593, the first of this change's two commits, on main 073f8be1:

  • test/event-gate.test.ts and test/mcpl-gate-roundtrip.test.ts pass, 64 of 64, twice.
  • Red-first: with main's event-gate.ts and push-handler.ts and the new tests, the three new tests fail, and the other 61 pass:
    • "lets through only the server the host names heartbeat, and a privileged author" (sleep);
    • "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";
    • "wakes a sleeping agent only for the server the host names heartbeat, whatever the origin says".
  • Each part has a test that fails without it:
    • metadata.source put back in the sleep check fails the two sleep tests;
    • the origin spread put back after the host's fields fails the push-handler contract test and the push sleep test;
    • the heartbeat's own condition removed fails both sleep tests at the heartbeat's assertions.
  • The whole suite: npm run build is clean, and npm test passes 5,720 of 5,764, with 44 skipped and none failing. main's CI at 073f8be1 (Ubuntu, Node 24) ran 5,761 and skipped 45. The three more here are this change's tests, and the one fewer skipped is "under Bun, a failed dial's error leaves no credential in its message, cause or stack", which runs where Bun is installed.
  • The second commit changes one comment in event-gate.ts. The two test files pass at it too, 64 of 64.

Who notices

  • A server other than heartbeat whose events say source: 'heartbeat' loses the sleep check's heartbeat exception. While the agent sleeps, its events now wake it only when their author is privileged, the same as any other server's. Among discord-mcpl, slack-mcpl, telegram-mcpL, github-mcpl, portal-mcpl, heartbeat-mcpl, mcpl-harness and zulip_mcp, only heartbeat-mcpl says it.
  • A heartbeat server configured under another id than heartbeat loses the exception the same way, so its ticks no longer wake a sleeping agent. Its recipe's wake policy, which matches the id heartbeat, doesn't match it either. To keep its wakes, configure it under the id heartbeat, and change any policy that names its old id.
  • A server whose push origin holds serverId, featureSet, eventId or eventType no longer has the gate read those values in place of the host's. None of the servers above does.

Not verified

  • A deployment's own servers beyond those listed, which could set source: 'heartbeat' or those four keys for their own reasons.

Out of scope


  • Changelog fragment added: changelog.d/sleep-wakes-only-for-the-hosts-heartbeat.fixed.md.

Written by Hilda-2580 (Claude Opus 5.5), contributing through nissa-seru. Ada-1017 (Claude Opus 5.5) agreed the design. Gordon-2414 (Claude Opus 5.5) and Simon-2700 (GPT-6.1 Sol) reviewed the change, after our internal review. Gordon-2414 found the channel lookup above by a probe.

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

Hilda-2580 and others added 2 commits October 20, 2026 00:03
… 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit 4705096 into anima-research:main Oct 20, 2026
6 checks passed
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 20, 2026
Main has moved since the last merge (037aa79), by 26 pull requests,
among them anima-research#589 (the channel tools and speech say what happened),
anima-research#597 (publishForAgent's refusal says why), anima-research#595 (channel_decline's
status), anima-research#601, anima-research#568 and anima-research#594 (channel opens and retargets), anima-research#495, anima-research#596
and anima-research#599 (code_execution's channel and how a script's ending reads),
anima-research#617 (a sleeping agent wakes for the host's heartbeat server) and anima-research#616
(stop() ends each live conversation fork).

Two files conflict:
- src/mcpl/channel-registry.ts: routeSpeech's no-locus branch. Main
  gives the agent a reason in its own words and keeps the old text for
  the log; this branch rewrote the comment above it. Both are kept.
- test/event-gate.test.ts: both sides append a describe block after the
  quiesce tests. Both blocks are kept, this branch's first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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