Skip to content

feat(heartbeat): the heartbeat moves into the framework - #827

Merged
antra-tess merged 7 commits into
mainfrom
feat/core-heartbeat
Oct 27, 2026
Merged

antra-tess merged 7 commits into
mainfrom
feat/core-heartbeat

Conversation

@antra-tess

@antra-tess antra-tess commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The heartbeat (the agent's periodic check-in and its reminders) lives in heartbeat-mcpl, an MCPL server. The framework never knows when the agent's next scheduled wake is. The reply judge (#801) is told what will wake the agent if it lets a batch wait, and today it can't count the heartbeat or a reminder due in five minutes. Keeping the heartbeat in a server also costs a process, a policy exchange and push delivery, with their own failure modes (pre-0.5 hosts, refused pushes, held reminders), for what is the agent's own timer.

Changes

src/heartbeat/heartbeat.ts: the heartbeat in the framework, behind FrameworkConfig.heartbeat (true, or { statePath?, defaults?, importFrom? }).

  • What it keeps. The ambient tick (interval of 60 s or more, paused, message, deliveryMode message or silent) and reminders (one-off by delay or atIso, repeating every 5 s or more). The six tools keep the names agents know them by: heartbeat--heartbeat_status, --heartbeat_configure, --heartbeat_trigger, --reminder_add, --reminder_list and --reminder_cancel. Their arguments are read as wake_add_rule's are (checkToolInput: slips and misplaced fields refused, unknown names noted). reminder-time.ts (atIso in the agent's zone, DST gaps and folds refused, naming offsets) is ported as it was. Texts keep heartbeat-mcpl's format to the second ([current time: …], [reminder <id>, set … · current time: …]).
  • Delivery, through the agent's rules (fa86f47, from nissa-seru's review). heartbeat-mcpl's pushes went through the gate's rules; only a sleep let the server id heartbeat through. So each tick and reminder goes to the gate as that push (heartbeatGateEvent: an mcpl:push-event from heartbeat), and its wake is queued only when the gate triggers one.
    • A rule for source: "heartbeat" and the gate's default govern it as they governed the server's pushes, a sleep still lets it through, and the probe table's heartbeat row describes it.
    • A message-mode tick's or reminder's text is stored whatever the rules decide, as a push's is.
    • Wakes go to the residents, not to conversation forks, ephemeral runs or the subconscious. As for a stored push, the delivery agent's wake is dropped once a request of its own has carried and answered the text (readsWriteFor, main's rename of readsDeferredFor); a co-resident's isn't tied to it.
    • A silent tick, when the gate triggers, goes through the existing silent-heartbeat path (suppressProse, silentHeartbeat with serverId: 'heartbeat', no stored prompt).
  • State. One file, by default {storePath}/config/heartbeat.json, written atomically.
    • The next tick's time is kept, so a restart no longer pushes the tick back by a full interval.
    • A tick or reminder that fell due while the host was down fires once, 10 s after start; a repeating reminder fires once for the periods it missed.
    • An unreadable file is moved aside to .unreadable-<ms>; one that can't be read at all isn't written over.
    • A message-only change no longer resets the next tick; changing the interval or resuming starts the next interval now.
  • Moving off the server. importFrom: { configPath, remindersPath } reads heartbeat-mcpl's two files on a first start (no state file yet), its saved null interval loaded paused as the server did, and leaves them as they are.
  • No double heartbeat. heartbeat-mcpl is refused beside it, naming both fixes, at AgentFramework.create and at a runtime connect or restart (mcplPrefixCollision). It's recognized by an MCPL server with the id heartbeat, a tool prefix of heartbeat or mcpl--heartbeat, or a command that runs heartbeat-mcpl (isHeartbeatMcplServer).
  • Config checked. heartbeat must be true, false, null or an object whose paths are text. "false" or 0 is refused rather than read as on, and null leaves it off.
  • Old names still work. Residents know the tools as mcpl--heartbeat--…, heartbeat-mcpl under the default mcpl--<id> prefix, and their contexts hold calls by those names. Those calls reach the same tools, and the result names the new one. An agent's allowedTools that names a heartbeat tool under either prefix allows it under both (Agent.canUseTool).
  • Every call path. The tools answer an agent's turn, executeToolCall, a puppet, and a module's callTool (executeToolCallFrom; a module is trusted, as for utils).
  • Only residents change it. A conversation fork or an ephemeral run can call heartbeat_status and reminder_list, but changing the schedule or setting a reminder is refused, as their sleep is. Those reminders would wake the residents, not them. The subconscious's fixed tool surface doesn't include the heartbeat's tools.
  • Retries. A tick or reminder whose delivery throws is tried again a minute later. A reminder's retry (retryAt) keeps its due time for its header's "due" and its repeat's rhythm.
  • Imports finish. An import that can't read one of heartbeat-mcpl's files saves nothing, so the next start finishes it; the tools say "Not saved" meanwhile. A later start that reads the state file saves again.
  • For the judge. upcomingWakesFor adds the next tick ("its heartbeat (a check-in prompt, every 4 h)") and each pending reminder, with its message, to the <if_you_wait> list. One the agent's rules would hold back (by the gate's probe) is listed as only a "maybe".
  • Exports. Heartbeat, DEFAULT_HEARTBEAT, HEARTBEAT_TOOL_PREFIX and the option types from the package root.

What it drops from the server, since the framework delivers directly: the MCPL policy handshake, delivery=BLOCKED states, refused or held pushes, and the silent-to-message fallback. heartbeat-mcpl stays usable on hosts that don't turn this on.

Companion PRs / merge order

Tests

test/heartbeat.test.ts (26 tests, with mocked timers and clock):

  • Tick: at each interval with the timestamped message; silent; paused; heartbeat_trigger.
  • Configure: units add up; a message change keeps the next tick; refusals (under the floor, negative, a bad mode, a blank message); a slip (intervalMinute) refused; an unknown name noted.
  • Reminders: a one-off fires once with its exact header and is gone; a repeating one fires each interval and stays until cancelled; atIso read in America/New_York; refusals (no time, delay and atIso both, under 5 s, blank message, a DST gap).
  • Restarts: two hours down, then each overdue wake fires once after the grace, with "due"; heartbeat-mcpl's files imported, with a bad row dropped and the files left; its null interval imported paused; an unreadable state file moved aside.
  • Judge list: upcoming wakes, soonest first, and none for a paused tick.
  • Text and time: a late reminder's text; reading in a zone and refusing a fold.
  • Failures and old names: a call by heartbeat-mcpl's name; a tick whose delivery throws retried in a minute, and heartbeat_trigger saying it failed; a reminder's retry keeping its due time and rhythm; a later start saving again; a partial import saving nothing until a start finishes it.
  • The gate's rules (fa86f47): with no rules a tick wakes; a skip rule for source: "heartbeat" holds it back while its text is stored, the probe's heartbeat row agrees, and the judge hears "maybe"; a sleep lets it through; a defer default holds it back.
  • Review round (fa86f47): executeToolCall reaches the tools; an ephemeral run isn't a target; an agent allowed mcpl--heartbeat--heartbeat_status may call heartbeat--heartbeat_status and not --reminder_add; heartbeat-mcpl refused at a runtime connect; "false", 0 and a non-text statePath refused, null leaving it off; a registered fork refused with nothing saved; a silent tick storing nothing; a repaired state file read by the next start; the import kept byte for byte and not redone when the old files change.
  • In the framework: the six tools listed; heartbeat_status routed through an agent's turn; a tick stores its prompt, queues a wake and the agent reads it; a silent tick queues a silent-heartbeat request with prose suppressed; the judge's list includes the tick; heartbeat-mcpl refused by id, prefix or command; framework.start() arms and stop() clears the tick; a fork's change refused while its status call answers.

npm run build clean. On #801's head (with #805 and main merged into it). At fa86f47, every event-gate* and skip-reply-wake* file plus reply-judge, heartbeat, gate-recipe-reconcile, provider-acceleration-cooldown and provider-waits, plus agent-allowed-tools, with #801's MCPL end-to-end test skipped (this environment can't reach channel registration): 598 pass / 0 fail. CI runs the full suite.

Not verified

  • Not run in a live host, or on a real resident's heartbeat-mcpl files: the import is tested on synthetic ones.
  • A silent tick end to end with a real provider: the request it queues is checked, and the silent path itself is unchanged.
  • The tools' replies differ in detail from heartbeat-mcpl's: no delivery= field, and different "not saved" notes and refusal wording. An agent that parsed them may notice.
  • The gate tests drive the framework's gate with rules and a sleep; heartbeat-mcpl's own pushes weren't run beside them here (nissa-seru's probe did that at 59c0ced3 and on main).

🤖 Generated with Claude Code

@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: merge and fast-follow. 8 medium severity findings can be fixed after the merge.

Reviewed commit 3cc01fa 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. The heartbeat is an opt-in, framework-owned wake source, built like the EventGate and the existing silent-heartbeat and skip_reply wake paths, and the reply judge needs to know about it.

6 reasons with sources

The PR follows the project's rules on stacked and companion PRs, changelog fragments and stating what was not verified.

  • It extends a heartbeat concept the core already owns, rather than adding a new one. Before this change, the framework already had special handling for silent heartbeat ticks (silent-heartbeat.ts, #216) and its own self-wake timer for skip_reply. The new Heartbeat sends its wakes through those same paths (suppressProse, silentHeartbeat), so it does not add a second delivery mechanism. (src/silent-heartbeat.ts; src/mcpl/skip-reply-wake.ts)
  • It is laid out like the existing core subsystems: its own src/ directory, an optional FrameworkConfig key, and a state file under {storePath}/config. The gate is configured by FrameworkConfig.gate, lives in src/gate/, stores {storePath}/config/gate.json, and has tool routing and lifecycle hooks in framework.ts. The heartbeat copies that layout: src/heartbeat/, FrameworkConfig.heartbeat, config/heartbeat.json, a routing branch next to wake_add_rule, and start/stop next to resumeSleep/dispose. It also reuses the gate's checkToolInput to read tool arguments. (src/framework.ts (EventGate setup, config/gate.json); src/gate/)
  • It is opt-in, and heartbeat-mcpl keeps working for hosts that don't turn it on. The README already counts both MCPL servers and timers as event sources. With heartbeat left unset nothing changes. Running both at once is refused when the framework is created, which keeps two heartbeats from running side by side. (README.md (Overview: external events include MCPL servers and timers))
  • It serves the reply judge, which can only count wakes the framework knows about. upcomingWakesFor already lists framework-known wakes such as provider cooldowns. A heartbeat kept in an external server can't add to that list, and that is the stated reason for the move. (src/framework.ts (upcomingWakesFor))
  • The PR follows the contribution conventions: companion and stacked PRs declared with merge order, a changelog fragment, a test count, and a Not verified section. The PR says it is stacked on #801 and names a companion PR in connectome-host that is safe to merge in either order. It adds changelog.d/core-heartbeat.added.md, reports 393 pass / 0 fail, and lists the checks it did not run. (CONTRIBUTING.md (How changes land; What a PR should contain; Changelog))
  • It adds new public exports and a config surface the maintainers will have to keep working, and the README isn't updated to describe it. The package root now exports Heartbeat, DEFAULT_HEARTBEAT, HEARTBEAT_TOOL_PREFIX and the option types. This matches how the gate and judge are exported, so it is not a scope problem, but the README's Core Concepts section does not mention it. (README.md; src/index.ts)

Findings

8 findings of medium or high severity, see the inline comments:

3 more findings, less severe
  • Low severity, test coverage, test/heartbeat.test.ts:214-221. The state file's unsaveable paths and field validation are untested. Only a torn JSON file is tested. Nothing covers a file that can't be read or can't be renamed (the unsaveable branch and its "Not saved" note in tool replies), or readState/readSettings rejecting bad fields such as a sub-60 interval or a bad deliveryMode. A file with v ≠ 1 being moved aside is also untested.
  • Low severity, test coverage, test/heartbeat.test.ts:296-300. Excluding conversation forks and the subconscious from the heartbeat is untested. heartbeatTargets() decides who gets the ticks and reminders, and whose judge list shows them through upcomingWakesFor. The tests use only one resident agent, so a regression that wakes forks or the subconscious, or lists the heartbeat in their judge prompt, would not be caught.
  • Low severity, documentation, src/heartbeat/heartbeat.ts:12-15. The module comment points to EventGate.upcomingWakes, but the judge is fed by AgentFramework.upcomingWakesFor. The header says the reply judge is told about the heartbeat through EventGate.upcomingWakes. The heartbeat's wakes are actually added in AgentFramework.upcomingWakesFor (src/framework.ts), and EventGate knows nothing of the heartbeat. A maintainer following the comment will look in the wrong place.

For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.

Comment thread src/framework.ts
Comment on lines +5082 to +5097
/** Handle a call to one of the heartbeat's tools. */
private dispatchHeartbeatToolCall(agentName: string, call: ToolCall): void {
this.emitTrace({ type: 'tool:started', module: 'heartbeat', tool: call.name, callId: call.id, input: call.input });
let result: ToolResult;
try {
result = this.heartbeat!.handleToolCall(call.name, call.input);
} catch (err) {
result = { success: false, error: `${call.name} failed: ${errorMessage(err)}`, isError: true };
}
this.emitTrace({
type: result.isError ? 'tool:failed' : 'tool:completed',
module: 'heartbeat', tool: call.name, callId: call.id, durationMs: 0,
...(result.isError ? { error: result.error } : {}),
});
this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'heartbeat', result });
}

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. Conversation forks and ephemeral runs can reconfigure the resident's heartbeat and set reminders that never wake them.

dispatchHeartbeatToolCall runs any agent's call, and getAllTools offers the six tools to every agent. Yet heartbeatTargets() delivers only to residents, leaving out forks and the subconscious. A conversation fork, which is driven by a channel, can pause or retime the agent's shared heartbeat. A fork or ephemeral run that calls reminder_add sets a reminder that wakes the residents, not itself. The nearest existing framework-owned schedule, the sleep tool, has the same problem and solves it: dispatchSleepToolCall (around line 31269) refuses forks and ephemeral runs, because "a sleep is the agent's". The heartbeat should refuse these callers in the same way, or route their wakes to them.

Comment on lines +476 to +508
if (this.state.reminders.length === before) {
return this.refuse('reminder_cancel', [`No reminder has the id "${id}". If it was a one-off that has fired, or it was cancelled already, there's nothing more to do; otherwise reminder_list shows the pending ids.`]);
}
this.save();
this.armReminders();
log(`cancelled reminder ${id}`);
return { text: `Cancelled ${id}. ${this.state.reminders.length} reminder(s) remain.${this.saveNote()}` };
}

// ===========================================================================
// Timers and delivery
// ===========================================================================

private armTick(): void {
this.cancelTick?.();
this.cancelTick = null;
const at = this.state.nextFireAt;
if (!this.started || this.state.settings.paused || at === null) return;
this.cancelTick = callAt(Math.max(at, this.notBefore), this.now, () => {
this.cancelTick = null;
this.fireTick('schedule');
this.state.nextFireAt = this.now() + this.state.settings.intervalSeconds * 1000;
this.save();
this.armTick();
});
}

/** Deliver one tick; false when delivery threw. */
private fireTick(reason: 'schedule' | 'manual'): boolean {
const now = this.now();
const s = this.state.settings;
const silent = s.deliveryMode === 'silent';
try {

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 scheduled tick whose delivery throws is dropped, not retried.

In armTick's callback, fireTick('schedule') returns false when deliver throws. Its result is ignored: nextFireAt still moves a full interval ahead (4 h by default) and that tick is lost. Reminders handle the same failure by retrying after RETRY_MS (fireDueReminders), and the RETRY_MS comment says "A delivery that throws is tried again this much later." A transient failure in deliverHeartbeat, such as addMessage throwing, therefore costs the agent a whole heartbeat period with only a log line. Retry the tick after RETRY_MS as reminders do, or narrow the comment.

Comment on lines +524 to +533
private armReminders(): void {
this.cancelReminders?.();
this.cancelReminders = null;
if (!this.started || this.state.reminders.length === 0) return;
const due = Math.min(...this.state.reminders.map((r) => r.fireAt));
this.cancelReminders = callAt(Math.max(due, this.notBefore), this.now, () => {
this.cancelReminders = null;
this.fireDueReminders();
});
}

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 retried reminder loses its original due time, so its "due" header and repeat rhythm are wrong.

When delivery throws, the reminder is rewritten with fireAt: now + RETRY_MS. On the next attempt, reminderText checks lateness against this new fireAt, so it leaves out the , due … note or gives the retry time instead of the time the agent asked for. For a repeating reminder, the next period is then computed from the retry time (rem.fireAt + everySeconds), so its rhythm shifts by the retry delay. Keep the original due time separate from the retry time.

Comment thread src/framework.ts Outdated
Comment on lines +3921 to +3928
if (config.heartbeat !== undefined && config.heartbeat !== false) {
if ((config.mcplServers ?? []).some((s) => s.id === 'heartbeat')) {
throw new Error(
'FrameworkConfig.heartbeat is on beside an MCPL server with the id "heartbeat" (heartbeat-mcpl): both would wake '
+ 'the agent, and their tools share the names heartbeat--…. Remove the server to use the framework\'s heartbeat '
+ '(heartbeat.importFrom can bring its schedule and reminders along), or turn heartbeat off to keep the server.',
);
}

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. The double-heartbeat check matches only the server id, not the tool prefix that produces heartbeat-- names.

The tool listing (around line 29867) names MCPL tools ${config.toolPrefix ?? mcpl--${server.id}}--${tool}. A server only produces heartbeat--… names when its toolPrefix is heartbeat, and the server id plays no part in that. The new create-time check refuses only s.id === 'heartbeat'. Two cases get past it. heartbeat-mcpl registered under another id with toolPrefix: 'heartbeat' passes the check; its tools are then shadowed by the framework's routing at line 24260 while its pushes still wake the agent, so the agent is woken twice. heartbeat-mcpl registered under id 'heartbeat' without a toolPrefix is refused, even though its tools (mcpl--heartbeat--…) don't collide. The check should look at the effective prefix as well as the id. The claim that agents keep the tool names they knew also holds only for hosts that set this prefix.

Comment on lines +556 to +572
log(`fired reminder ${rem.id} (one-shot)`);
continue;
}
// A repeating reminder fires once for periods it missed, then keeps its rhythm from now.
let next = rem.fireAt + rem.everySeconds * 1000;
if (next <= now) next = now + rem.everySeconds * 1000;
if (canHoldTime(next)) {
this.state.reminders[i] = { ...rem, fireAt: next };
} else {
this.state.reminders.splice(i, 1);
log(`ended repeating reminder ${rem.id}: its next time is past ${LAST_REMINDER_TIME}`);
}
log(`fired reminder ${rem.id} (repeating)`);
}
this.save();
this.armReminders();
}

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. unsaveable is never cleared, so a state file read successfully on a later start is still never written.

load() sets this.unsaveable when the state file can't be read or can't be moved aside, but nothing resets it to null. If a framework is stopped and started again in the same process (start() calls load() again), a read that now succeeds still leaves unsaveable set. save() then returns early forever, and every change the agent makes is silently lost on restart. The message itself says "nothing is written over it until a start reads it", so load() should reset unsaveable (and lastSaveError) when it begins.

Comment on lines +602 to +630

/** The state a file holds, or undefined when it isn't state this framework writes. */
private readState(text: string): HeartbeatState | undefined {
let raw: unknown;
try {
raw = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
} catch {
return undefined;
}
if (!isObject(raw) || raw.v !== 1 || !isObject(raw.settings) || !Array.isArray(raw.reminders)) return undefined;
const settings = { ...this.defaults, ...readSettings(raw.settings, this.defaults, (note) => log(`${this.statePath}: ${note}`)) };
const nextFireAt = typeof raw.nextFireAt === 'number' && canHoldTime(raw.nextFireAt) ? raw.nextFireAt : null;
return { v: 1, settings, nextFireAt, reminders: readReminders(raw.reminders, (note) => log(`${this.statePath}: ${note}`)) };
}

/** heartbeat-mcpl's files, read once when there's no state file and the host names them. */
private imported(): HeartbeatState | undefined {
const from = this.importFrom;
if (!from?.configPath && !from?.remindersPath) return undefined;
const readJson = (path: string | undefined, what: string): unknown => {
if (!path || !existsSync(path)) return undefined;
try {
const text = readFileSync(path, 'utf-8');
return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
} catch (err) {
log(`couldn't import heartbeat-mcpl's ${what} from ${path} (${(err as Error).message}); left as it is`);
return undefined;
}
};

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 failed import of heartbeat-mcpl's schedule is never retried, though the log says the file was left for later.

imported() goes ahead when only one of the two files parses. For example, if the config file is corrupt but the reminders file is fine, it returns a state built on the defaults. start() then saves that state, so the state file exists and imported() never runs again. The agent's interval, message, mode and paused setting from heartbeat-mcpl are lost for good. Meanwhile the log says "couldn't import … left as it is", which implies a later import. Either refuse the partial import and write no state, or say plainly that the schedule falls back to the defaults.

Comment thread test/heartbeat.test.ts
Comment on lines +256 to +260
const own = mkdtempSync(join(tmpdir(), 'heartbeat-fw-'));
const framework = await AgentFramework.create({
storePath: join(own, 'store'),
membrane: membrane.asMembrane(),
agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.', proseRouting: 'disabled' }],

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. The framework tests start the heartbeat by hand, so the start()/stop() wiring is never tested.

Both framework tests call heartbeat.start() through a cast instead of framework.start(), and nothing checks that framework.stop() clears the timers. Removing this.heartbeat?.start() from AgentFramework.start or the step(() => this.heartbeat?.stop()) from stop would not fail any test. In that case no tick ever fires in production, or a stopped framework keeps delivering wakes. Add a test that goes through framework.start/stop and sees a tick fire, then stop firing.

Comment thread test/heartbeat.test.ts
Comment on lines +100 to +160
hb.stop();
});
});

describe('heartbeat: reminders', () => {
it('a one-off fires once with its header, then is gone', () => {
const { hb, wakes } = heartbeat();
hb.start();
const r = call(hb, 'reminder_add', { message: 'water the tomatoes', inMinutes: 5 });
assert.equal(r.success, true);
const id = /Reminder (rem_\S+) set/.exec(r.data!)![1]!;
mock.timers.tick(5 * 60_000);
assert.equal(wakes.length, 1);
const w = wakes[0]!;
assert.ok(w.kind === 'reminder' && w.reminderId === id && !w.recurring);
assert.equal(w.text, `[reminder ${id}, set 2026-10-26T12:00:00+00:00 [UTC] · current time: 2026-10-26T12:05:00+00:00 [UTC]] water the tomatoes`);
assert.deepEqual(saved().reminders, []);
assert.match(call(hb, 'reminder_list').data!, /No reminders set/);
hb.stop();
});

it('a repeating one fires each interval and stays; cancel ends it', () => {
const { hb, wakes } = heartbeat();
hb.start();
const id = /Reminder (rem_\S+) set/.exec(call(hb, 'reminder_add', { message: 'stretch', everyMinutes: 30 }).data!)![1]!;
mock.timers.tick(30 * 60_000);
mock.timers.tick(30 * 60_000);
assert.equal(wakes.filter((w) => w.kind === 'reminder').length, 2);
assert.match(wakes[0]!.text, /repeats every 1800s/);
assert.match(call(hb, 'reminder_list').data!, new RegExp(`1 reminder\\(s\\):\\n${id} \\| due .* \\| repeats every 1800s \\| "stretch"`));
assert.equal(call(hb, 'reminder_cancel', { id }).success, true);
mock.timers.tick(60 * 60_000);
assert.equal(wakes.filter((w) => w.kind === 'reminder').length, 2);
assert.equal(call(hb, 'reminder_cancel', { id }).success, false);
hb.stop();
});

it('reads atIso in the agent\'s zone and refuses what can\'t be scheduled', () => {
const { hb, wakes } = heartbeat({ timeZone: 'America/New_York' });
hb.start();
assert.equal(call(hb, 'reminder_add', { message: 'call', atIso: '2026-10-26T09:30' }).success, true); // 13:30Z
mock.timers.tick(90 * 60_000 - 1);
assert.equal(wakes.length, 0);
mock.timers.tick(1);
assert.equal(wakes.length, 1);
for (const [input, says] of [
[{ message: 'x' }, /No time was given/],
[{ message: 'x', inMinutes: 5, atIso: '2026-10-27T09:00' }, /"atIso" and a delay both say when it first fires/],
[{ message: 'x', everySeconds: 2 }, /under the 5-second floor/],
[{ message: '', inMinutes: 1 }, /"message" is missing; give the text to deliver when it fires\. heartbeat--reminder_add has "message" as "", so it's left out/],
[{ message: 'x', atIso: '2026-03-08T02:30' }, /doesn't exist in America\/New_York/],
] as const) {
const refused = call(hb, 'reminder_add', input);
assert.equal(refused.success, false, JSON.stringify(input));
assert.match(refused.error!, says);
}
hb.stop();
});
});

describe('heartbeat: state across restarts', () => {

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 delivery failure: reminder retry and trigger refusal are unexercised.

Several paths have no test: fireDueReminders' catch branch (reschedule after RETRY_MS), trigger() returning an error when deliver throws, and a scheduled tick whose delivery throws. Each is new behaviour described in the code. A test should give a deliver that throws once and check the retry time, the text of the second delivery, and the refusal returned by heartbeat_trigger.

antra-tess added a commit that referenced this pull request Oct 26, 2026
From the automated review of #827:
- Residents know the tools as mcpl--heartbeat--… (heartbeat-mcpl under the
  default prefix); calls by those names reach the same tools, and say the
  new name.
- A conversation fork, an ephemeral run or the subconscious may show the
  schedule but not change it (as for sleep).
- A tick whose delivery throws is tried again in a minute, not an
  interval later; a reminder's retry keeps its due time (retryAt), for its
  header's "due" and its repeat's rhythm.
- heartbeat-mcpl is refused beside it by id, by a heartbeat tool prefix,
  or by the command that runs it.
- A later start that reads the state file saves again; an import that
  can't read one of heartbeat-mcpl's files saves nothing, so the next
  start finishes it.
- Tests: framework.start()/stop() arm and clear the tick; a fork's change
  refused while status answers; each delivery failure; old names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor

/review deep

@nissa-seru

Copy link
Copy Markdown
Contributor

@antra-tess One point about delivery, and two notes on merging with #805 and #819. 879da7c8 already covers what we'd have raised about forks, retries, running heartbeat-mcpl beside it, and the import.

Ticks and the gate's rules. The changelog says the heartbeat's wakes are delivered "as the server's were: past the gate's rules and through a sleep". Before this pull request, on feat/reply-judge as on main, heartbeat-mcpl's pushes go through the gate's rules. PushHandler asks the gate's trigger callback about every push it accepts (src/mcpl/push-handler.ts, step 4), and EventGate.evaluate exempts the server id heartbeat only from a sleep, then matches its policies as for any event.

We ran it at 59c0ced3 and at main's 139e8a9c, with a test MCPL server registered under the id heartbeat in heartbeat-mcpl's place. Its push woke the agent with no rules. It didn't under a rule matching source: "heartbeat" with skip, or under no rules with default: "defer". Under a sleep its push still woke the agent, while the same push from a server with another id didn't. At 879da7c8, heartbeat--heartbeat_trigger woke the agent under all three of those gates. So a rule that matches heartbeat pushes, or a gate whose default is defer, governs the heartbeat today and wouldn't with this pull request.

We think ticks and reminders should keep going through the rules. The framework could ask eventGate.evaluate about the event a heartbeat-mcpl push gives (eventType: 'mcpl:push-event', serverId: 'heartbeat') before queuing a tick's or a reminder's wakes, and queue them only when it triggers. The sleep exemption comes with that, since evaluate already lets the server id heartbeat through a sleep, and the heartbeat row of the probe table that wake_add_rule reports would then describe what the framework does. Keeping the bypass instead needs the changelog to say that ticks no longer pass the gate's rules, since "as the server's were" tells a resident with such a rule that nothing changed.

Merging.

Written by Crispin-2713.

— Fiona-2757

@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 here or file an issue for each, then merge. 13 should-fix issues should be fixed in this pull request or filed as an issue before merging.

Reviewed commit 879da7c by reading the code, in four stages: a plan by claude-opus-5-5; two readers on every task of it, gpt-6.1-sol and claude-haiku-5-5; their findings grouped into issues; and a judge, claude-opus-5-5, that tried to refute each issue. Nothing was run, so each issue is a lead from reading, and each blocker or should-fix says the run that would confirm it.

Between them, the reads of the readers that answered returned every changed line of the change (1485 in all). A line counts when a read by a reader that answered returned it whole: an added line from the diff or from its file, a removed line from the diff. A search that matched it doesn't count. The head has no lockfile at its root, so its dependencies were installed from package.json's version ranges, and a dependency the readers read may be a later version than the author ran.

Should fix (13)

Minor (22)

22 minor issues, by file
  • The one-minute retry promise is unqualified, but a failed manual heartbeat_trigger is never retried. changelog.d/core-heartbeat.added.md:7, src/heartbeat/heartbeat.ts:414, src/heartbeat/heartbeat.ts:511 and 2 more places The changelog (changelog.d/core-heartbeat.added.md:7) and the description say a tick whose delivery throws "is tried again a minute later". By reading, that holds only for scheduled ticks (src/heartbeat/heartbeat.ts:512). A failed heartbeat_trigger (heartbeat.ts:415) only returns an error and arms no retry. The text should say the retry applies to scheduled ticks.
  • Heartbeat tools are usable before start() loads persisted state, so a save writes constructor defaults over the file. src/framework.ts:4236, src/framework.ts:6410, src/framework.ts:12479 and 6 more places Heartbeat state stays at constructor defaults until start() calls load() (src/heartbeat/heartbeat.ts:254, 263), but save() (679) has no started/loaded guard. A reminder_add or heartbeat_configure call driven via runUntilIdle() without framework.start() (src/framework.ts:12479) would write defaults plus the change over an existing heartbeat.json. Only the test helper reaches this path.
  • The coexistence guard checks only exact heartbeat prefixes and a command substring, while MCPL resolution runs first, so an ancestor prefix like 'mcpl' steals legacy heartbeat calls. src/framework.ts:3925, src/framework.ts:24075, src/framework.ts:24197 and 14 more places The create check at src/framework.ts:3925-3927 rejects only exact heartbeat prefixes. resolveMcplTool (src/framework.ts:24075-24083) runs at :24197, before the heartbeat route at :24282, and matches any prefix + '--'. So an MCPL server with toolPrefix 'mcpl' is accepted and takes mcpl--heartbeat--* calls, so the description's "Old names still work" fails. The legacy-name test calls handleToolCall directly, so it never goes through dispatch.
  • Heartbeat timers stay armed through stop()'s asynchronous waits, and delivery reports success after admission closes. src/framework.ts:4469, src/framework.ts:4473, src/framework.ts:4586 and 7 more places heartbeat.stop() runs only after stop()'s asynchronous waits (src/framework.ts:4586-4632, :4729), and deliverHeartbeat (:5062) doesn't refuse once admission is closed (:4473). A tick or one-off reminder that falls due during teardown queues an in-memory wake no turn will run. The heartbeat then saves the next tick time, or removes the reminder (src/heartbeat/heartbeat.ts:512, :571), so the wake is lost across a restart.
  • The description claims tool names are kept, but the advertised names change from mcpl--heartbeat-- to heartbeat--, and the resulting cache miss isn't mentioned. src/framework.ts:6410 The description says 'The six tools keep the names agents know them by: heartbeat--…', and changelog.d/core-heartbeat.added.md:1 says the same. Yet residents under heartbeat-mcpl's default prefix know them as mcpl--heartbeat--… (src/heartbeat/heartbeat.ts:42), and the description's own 'Old names still work' paragraph says so. The advertised names do change for those agents. The claim should say heartbeat--… is the new canonical name.
  • save() renames the temp file without fsyncing the file or its directory. src/heartbeat/heartbeat.ts:684, src/heartbeat/heartbeat.ts:685, src/heartbeat/heartbeat.ts:683 and 4 more places save() at src/heartbeat/heartbeat.ts:684-685 renames the temp file over heartbeat.json without fsyncing it or its directory, unlike writeRecoveryFile (src/framework.ts:7962, 7978). After a power loss the file can be empty, and load() (610-618) would then move it aside and start with no reminders and the default schedule.
  • The state file's existence is the only import-completion marker, so lost state triggers reimport of stale legacy reminders. src/heartbeat/heartbeat.ts:599, src/heartbeat/heartbeat.ts:620, src/heartbeat/heartbeat.ts:674 and 1 more place Only the state file's existence marks the import as done (src/heartbeat/heartbeat.ts:599, 620). If a damaged file is moved aside (612) and the save that follows fails, or heartbeat.json is otherwise lost, the next start re-imports heartbeat-mcpl's untouched files. Reminders that already fired or were cancelled are then re-armed and fire again, and the schedule reverts.
  • Using existsSync to tell missing from unreadable state/import files bypasses the no-overwrite and unfinished-import protections on permission errors. src/heartbeat/heartbeat.ts:599, src/heartbeat/heartbeat.ts:646, src/heartbeat/heartbeat.ts:679 existsSync (src/heartbeat/heartbeat.ts:599, 646) reports false on EACCES as well as ENOENT. A state file under an unsearchable directory therefore loads defaults without setting unsaveable, and a save after permissions are repaired (e.g. the tick at 513) would overwrite it. An unsearchable import source counts as absent, so a partial or empty import is saved as finished.
  • An unfinished import still arms and consumes imported reminders while saving is disabled. src/heartbeat/heartbeat.ts:658, src/heartbeat/heartbeat.ts:676, src/heartbeat/heartbeat.ts:272 and 3 more places During an unfinished import (src/heartbeat/heartbeat.ts:658) the timers are still armed (271-272). Imported reminders fire and are removed in memory (571), but save() is a no-op (680). The next start re-imports the untouched legacy files, so already-delivered one-shot reminders fire again and cancellations made meanwhile are lost.
  • Delivery isn't replay-safe: the prompt is committed before consumption is checkpointed, and retries use a new eventId while treating any throw as non-delivery. src/heartbeat/heartbeat.ts:416, src/heartbeat/heartbeat.ts:511, src/heartbeat/heartbeat.ts:513 and 6 more places By reading, the prompt is stored at src/framework.ts:5079 before the state is saved (src/heartbeat/heartbeat.ts:513, :587), and every attempt mints a new random eventId (:529, :562). A kill in that window, or a throw after context-manager 0.22.1's append has committed (message-store.js:406), leads the retry or restart to store the prompt again. heartbeat_trigger would also report "no heartbeat was sent" (:416). The window is narrow.
  • The judge's empty-list text still says a server's heartbeat may not be listed, though the framework now lists its own heartbeat. src/heartbeat/heartbeat.ts:296, src/gate/reply-judge.ts:195, test/heartbeat.test.ts:230 and 2 more places When the framework heartbeat is on but paused and no reminders are pending, upcomingWakesFor (src/framework.ts:5045) lists nothing. ifYouWait (src/gate/reply-judge.ts:195) then still tells the judge that a heartbeat "may not be listed", although the host owns the heartbeat and knows none is due. This may tilt the judge toward WAIT. By reading, the paused-heartbeat test doesn't check this wording.
  • The startup grace notBefore clamps every tick and reminder deadline, not just ones overdue at startup. src/heartbeat/heartbeat.ts:262, src/heartbeat/heartbeat.ts:508, src/heartbeat/heartbeat.ts:544 and 6 more places The 10 s start grace clamps every deadline, not only those overdue at start (src/heartbeat/heartbeat.ts:262, :508, :544). A saved tick due 5 s after start fires at start+10 s, and later ticks shift with it. A reminder added just after start with a short delay or a past atIso waits for the grace, despite 'A past time fires at once' (:150). The judge list (:301, :309) shows the clamped time.
  • Agent-facing failure replies show host paths/OS errors or point to the log, and don't say what's effective, whether to retry, or who must fix it. src/heartbeat/heartbeat.ts:374, src/heartbeat/heartbeat.ts:375, src/heartbeat/heartbeat.ts:416 and 5 more places The agent's replies point at things only the operator can reach. heartbeat_trigger's failure says delivery failed 'as the log says' (src/heartbeat/heartbeat.ts:416), and the 'Not saved' notes quote the host's statePath and OS error and say nothing is saved 'until a start reads it' (src/heartbeat/heartbeat.ts:375, :604, :658). None says whether calling again helps or that the host operator must fix it.
  • A missing or wrong-shape named import source isn't treated as a failure, so the incomplete import is saved as finished. src/heartbeat/heartbeat.ts:646, src/heartbeat/heartbeat.ts:665, src/heartbeat/heartbeat.ts:674 and 3 more places By reading, a heartbeat-mcpl reminders file that parses but is not an array is taken as zero reminders without entering failed (src/heartbeat/heartbeat.ts:674). Because unsaveable stays unset, the state is saved and the import counts as finished. Its reminders are never imported, and the only note is a "0 reminder(s)" log line (heartbeat.ts:675).
  • readReminders accepts an out-of-range createdAt that the formatter can't handle. src/heartbeat/heartbeat.ts:727, src/heartbeat/heartbeat.ts:204, src/heartbeat/heartbeat.ts:561 and 1 more place readReminders (src/heartbeat/heartbeat.ts:727) checks createdAt only with typeof, not canHoldTime as it does for fireAt. A one-shot row with createdAt 1e20 loads. Then formatHeartbeatTime throws inside reminderText (line 204, called at 561), and the catch at 567 reschedules it every minute forever. The trigger is a damaged or hand-edited file.
  • Non-object tool input is silently replaced with an empty object. src/heartbeat/heartbeat.ts:323, src/heartbeat/heartbeat.ts:334, src/heartbeat/heartbeat.ts:342 By reading, src/heartbeat/heartbeat.ts:334 swaps any non-object input (an array, or a double-encoded JSON string) for {}. So heartbeat_trigger with ['x'] fires a tick, and heartbeat_configure with the string '{"paused":true}' answers 'No changes.' instead of refusing. Module tools refuse non-object input (src/tool-args.ts:172), but the heartbeat route (src/framework.ts:5109) checks nothing first.
  • Default and file-loaded intervals lack configure's representable-due-time check, so they can overflow nextFireAt to Infinity. src/heartbeat/heartbeat.ts:252, src/heartbeat/heartbeat.ts:268, src/heartbeat/heartbeat.ts:383 and 6 more places By reading, readSettings (src/heartbeat/heartbeat.ts:695-707) accepts any finite interval, without configure's canHoldTime check (line 383). For defaults or an imported intervalSeconds such as Number.MAX_VALUE, start (line 268) sets nextFireAt to Infinity. That is saved as null, and the judge is told of a certain wake 'in Infinity h' (src/gate/reply-judge.ts:173-178). An out-of-range finite due time is also dropped on reload (line 633).
  • The heartbeat file header names EventGate.upcomingWakes, but heartbeat wakes come from AgentFramework.upcomingWakesFor. src/heartbeat/heartbeat.ts:13, src/gate/event-gate.ts:3836, src/framework.ts:5045 The header at src/heartbeat/heartbeat.ts:13-14 says the judge learns of the heartbeat through EventGate.upcomingWakes. That method (src/gate/event-gate.ts:3836) lists only the gate's own timers. The heartbeat's wakes are added by AgentFramework.upcomingWakesFor (src/framework.ts:5045), so the comment points maintainers to the wrong place.
  • Unreadable-state backup names use only the current millisecond, so renameSync can overwrite an earlier backup. src/heartbeat/heartbeat.ts:610, src/heartbeat/heartbeat.ts:612 The backup for an unreadable state file is named only by the start's millisecond (src/heartbeat/heartbeat.ts:610), and renameSync (line 612) replaces an existing file. Two quarantines at the same now, possible with the injectable clock or a clock stepped back, would destroy the earlier backup.
  • The renamed-call note isn't added when a legacy-name call fails checkToolInput. src/heartbeat/heartbeat.ts:328, src/heartbeat/heartbeat.ts:334 When a call by the legacy mcpl--heartbeat--… name fails checkToolInput (a slip or misplaced field), the catch at src/heartbeat/heartbeat.ts:336 returns the error without the rename note that line 348 adds on other paths. The agent then reads an error naming heartbeat--… (src/gate/event-gate.ts:737), a tool it never called, with nothing explaining the rename.
  • formatHeartbeatTime uses Intl's era year without era, sign or padding, so accepted ISO years are shown wrongly. src/heartbeat/reminder-time.ts:41, src/heartbeat/heartbeat.ts:181, src/heartbeat/heartbeat.ts:188 and 4 more places Reading the code, formatHeartbeatTime (src/heartbeat/heartbeat.ts:181,188) prints Intl's unpadded year-of-era and requests no era. A reminder that parseReminderTime accepts (src/heartbeat/reminder-time.ts:41), such as atIso '0000-01-01T00:00Z' or '-000001-…', would be listed (heartbeat.ts:474) and headed with year '1' or a positive year. Years below 1000 would lose their four-digit padding. Only extreme dates are affected.
  • parseReminderTime range-checks the temporary UTC wall time before applying the offset. src/heartbeat/reminder-time.ts:67, src/heartbeat/reminder-time.ts:68, src/heartbeat/reminder-time.ts:70 By reading, parseReminderTime checks the Date range on the UTC-read wall time (src/heartbeat/reminder-time.ts:67-68) before it subtracts the given offset (line 71). So '+275760-09-13T01:00:00+01:00' is refused as out of range, though its instant is exactly LAST_REMINDER_TIME. '-271821-04-19T23:00:00-01:00' is refused the same way at the lower bound.

In existing code (1)

1 issue in code that was there before this change, which doesn't weigh in the recommendation
  • Minor. Parked-request collapse keys message and silent heartbeat ticks identically, so a newer silent tick can replace a message tick. src/framework.ts:15675, src/framework.ts:15682, src/framework.ts:15899 and 2 more places By reading, while the host is quiesced (not under a provider cooldown, whose holdRequest only dedups identical requests), src/framework.ts:15675 gives a parked message-mode tick (src/framework.ts:5083) and a later silent tick (src/framework.ts:5068) one key. The silent request is kept, so the resumed turn suppresses prose for the message tick's stored prompt. Base push events collapse the same way under 'mcpl:push-event' (src/framework.ts:15381).

}

/** Whether `name` is one of the heartbeat's tools, by its name or heartbeat-mcpl's. */
ownsTool(name: string): boolean {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should fix. Heartbeat alias canonicalization runs after exact-name allowedTools permission and tool-surface filtering, so legacy and new names don't share authority.

allowedTools is matched exactly (src/agent.ts:496), both for the tools offered to the model (src/agent.ts:1569) and for dispatch permission (src/framework.ts:24166), and both run before heartbeat aliasing (src/heartbeat/heartbeat.ts:292). An agent allowed only mcpl--heartbeat--* names is offered no heartbeat tool and is refused on the new names. One allowed only heartbeat--* names is refused on legacy calls. This contradicts the description's "Those calls reach the same tools".

To confirm: Configure an agent with allowedTools ['heartbeat--heartbeat_status'] and dispatch a call to mcpl--heartbeat--heartbeat_status; expect the 'not permitted by this agent's allowedTools' refusal. Separately, check the tool list sent to the provider for an agent that allows only the legacy names.

Comment thread src/framework.ts
}

/** The agents the heartbeat wakes: every resident but conversation forks and the subconscious, as a push event's default broadcast. */
private heartbeatTargets(): string[] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should fix. heartbeatTargets copies the broadcast filter that includes ephemeral runs, while the resident check and description treat ephemeral runs as non-residents.

heartbeatTargets (src/framework.ts:5051) keeps ephemeral runs, which are registered in this.agents (src/framework.ts:9541). So every tick, reminder and silent tick queues a turn for a live ephemeral run (src/framework.ts:5067, :5082). Meanwhile src/framework.ts:5096 treats such runs as non-residents, and the description says those reminders "would wake the residents, not them". Either exclude ephemeralRuns from the targets or correct the text.

To confirm: Start an ephemeral run, fire a reminder via mock timers, and check whether pendingRequests holds an entry for the run's agentName with reason 'reminder'.

Comment thread src/framework.ts
const metadata = wake.kind === 'reminder'
? { source: 'reminder', serverId: 'heartbeat', reminderId: wake.reminderId, recurring: wake.recurring, eventId: wake.eventId }
: { source: 'heartbeat', serverId: 'heartbeat', reason: wake.reason, eventId: wake.eventId };
const id = this.addMessage('user', [{ type: 'text', text: wake.text }], metadata as MessageMetadata, { placement });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should fix. Heartbeat text is written once to the primary's window, and readsDeferredFor marks only a write deferred for that same agent, so other residents' wakes aren't tied to the text.

A heartbeat wake is tied to its text only when the write was held for that same resident (src/framework.ts:5084, :26010). Stored at once, each resident's wake survives after another request answered the text and starts an extra turn. Held for the primary's turn, a co-resident's wake runs before the text lands and isn't replaced. The description's per-resident drop claim (src/framework.ts:5059) overstates this.

To confirm: With two residents, fire a message-mode tick while the primary's turn is in a tool call. Check whether the helper's request carries the tick text and whether it is woken again after the text lands. Separately, fire a tick while a request is being assembled and count the turns that follow.

Comment thread changelog.d/core-heartbeat.added.md Outdated
@@ -0,0 +1,8 @@
- The heartbeat moves into the framework (`FrameworkConfig.heartbeat`, src/heartbeat/heartbeat.ts). It was the heartbeat-mcpl server: the agent's own periodic check-in, in message or silent mode, and its one-off and repeating reminders. The tools keep the names agents know: `heartbeat--heartbeat_status`, `heartbeat--heartbeat_configure`, `heartbeat--heartbeat_trigger`, `heartbeat--reminder_add`, `heartbeat--reminder_list` and `heartbeat--reminder_cancel`. Wakes are delivered as the server's were: past the gate's rules and through a sleep. A message-mode tick and a reminder store their timestamped text, as before; a silent tick is a silent-heartbeat turn with prose suppressed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should fix, text. Changelog, description and comments say bypassing gate rules is delivery 'as before', but heartbeat-mcpl pushes were still evaluated by gate.js/gate.json.

The changelog (changelog.d/core-heartbeat.added.md:1), the module doc (src/heartbeat/heartbeat.ts:16-17), the deliverHeartbeat comment (src/framework.ts:5055) and the description's 'Delivery, as before' all say heartbeat wakes skip the gate's rules as heartbeat-mcpl's did. heartbeat-mcpl's pushes were exempt only from the sleep and still ran through gate.js and gate.json (src/gate/event-gate.ts:3068-3080). An operator's heartbeat rule now silently stops applying.

To confirm: In a base build with a gate.json policy that skips serverId 'heartbeat', push a heartbeat-mcpl event and check that it is skipped. At head, enable heartbeat: true with the same policy and check that the tick still queues a wake.

Comment thread test/heartbeat.test.ts
const results: Array<{ isError?: boolean; error?: string; data?: unknown }> = [];
const push = internals.pushEvent.bind(framework);
internals.pushEvent = (event) => { if (event.type === 'tool-result') results.push(event.result!); else push(event); };
internals.conversationAgentHomes.set('scout-conv-1', {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should fix, test. The fork-refusal test uses an unregistered caller and checks only the error text, so the conversation-fork exclusion isn't pinned.

The fork-refusal test (test/heartbeat.test.ts:448-453) uses a name added only to conversationAgentHomes, never to agents. heartbeatTargets() (src/framework.ts:5051) never includes it, so the refusal comes from the not-a-resident check. By reading, removing the fork exclusion would leave the test passing, and nothing checks that the reminder wasn't saved.

To confirm: Remove !this.conversationAgentHomes.has(n) && from heartbeatTargets and run test/heartbeat.test.ts. The fork test is expected to still pass. Registering a real fork in agents should make it fail.

Comment thread src/framework.ts
}

// Route the heartbeat's tools (heartbeat--…)
if (this.heartbeat?.ownsTool(enrichedCall.name)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should fix. The heartbeat is routed only in dispatchToolCall, not in executeToolCall, puppetToolCall or module callTool.

By reading, only dispatchToolCall routes the heartbeat tools (src/framework.ts:24282). executeToolCallFrom has no heartbeat branch and falls through to moduleRegistry.handleToolCall (src/framework.ts:22480). So executeToolCall, puppetToolCall and a module's callTool answer 'Module not found: heartbeat, so nothing was done' (src/module-registry.ts:342). Under heartbeat-mcpl these calls reached the tools through the MCPL route.

To confirm: With heartbeat:true and a started framework, call framework.executeToolCall({id:'h',name:'heartbeat--heartbeat_status',input:{},callerAgentName:resident}) and puppetToolCall(resident,'heartbeat--heartbeat_status',{}), then check whether the result is 'Module not found: heartbeat'.

Comment thread src/framework.ts Outdated
// heartbeat-mcpl by its id, by a tool prefix that names its tools as
// the framework's (or as the old names the framework still answers), or
// by the command that runs it under any id.
const beside = (config.mcplServers ?? []).find((s) => s.id === 'heartbeat'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should fix. The heartbeat-mcpl coexistence refusal exists only in create(), not in runtime connect/restart collision validation.

Only create() refuses heartbeat-mcpl beside the heartbeat (src/framework.ts:3925). A runtime connect or a restart validates with mcplPrefixCollision (src/framework.ts:28665, :28879, :28980), which never checks the heartbeat. A heartbeat-mcpl deployed at runtime is accepted and wakes the agent a second time. With toolPrefix 'heartbeat' its tools take heartbeat--* calls ahead of the built-in route (src/framework.ts:24197 before :24282).

To confirm: Create a framework with heartbeat:true and no MCPL servers, then call connectMcplServer({id:'heartbeat', command:'heartbeat-mcpl', ...}) or a server with toolPrefix:'heartbeat'. Check whether it is refused as create() refuses it.

Comment thread src/framework.ts Outdated

/** Handle a call to one of the heartbeat's tools. */
private dispatchHeartbeatToolCall(agentName: string, call: ToolCall): void {
// The heartbeat is the agent's, and wakes its residents: a conversation

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should fix. The subconscious's fixed tool surface and allowedTools omit the promised read-only heartbeat tools.

The changelog says the subconscious 'can show the schedule and reminders' (changelog.d/core-heartbeat.added.md:6), but it can't. Its tool surface omits the heartbeat tools (src/framework.ts:7071), and its hard-coded allowedTools leaves out heartbeat_status and reminder_list (src/framework.ts:12621). Any such call is refused as not permitted at src/framework.ts:24166, before the heartbeat route.

To confirm: Enable subconscious and heartbeat, list getToolsForAgent(subconsciousName), and dispatch heartbeat--heartbeat_status as the subconscious. Check whether the tool is missing and the call comes back 'not permitted by this agent's allowedTools'.

Comment thread test/heartbeat.test.ts
assert.ok(upcoming.some((w) => w.what === 'its heartbeat (a check-in prompt, every 60 min)'), JSON.stringify(upcoming));
});

it('delivers a silent tick as a silent-heartbeat wake with prose suppressed, storing no prompt', async (t) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should fix, test. The silent-tick test checks only request flags, never that no prompt was stored or kept in the request content.

The test at test/heartbeat.test.ts:377 is named "storing no prompt", but by reading it checks only the queued request's suppressProse and silentHeartbeat fields (lines 397-399). It never compares the store before and after the tick. A regression where the silent branch (src/framework.ts:5064) also stored wake.text would pass it.

To confirm: Add an addMessage call with wake.text in deliverHeartbeat's silent branch and run test/heartbeat.test.ts; the silent-tick test should still pass, which shows the gap.

Comment thread src/framework.ts Outdated
// The heartbeat, when the host turns it on: the agent's own schedule of
// wakes. heartbeat-mcpl beside it would wake the agent twice and offer
// its tools under the same names, so that's refused.
if (config.heartbeat !== undefined && config.heartbeat !== false) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should fix. The heartbeat config switch bypasses runtime validation and treats any non-false value as enabled.

By reading, nothing checks FrameworkConfig.heartbeat at runtime. src/framework.ts:3921 treats any value except undefined and false as on. A YAML/JSON value of 'false' or 0 therefore starts a default four-hour heartbeat, and null throws a TypeError on opts.statePath (src/framework.ts:3938). The neighbouring switches are refused cleanly by assertFrameworkSwitches (src/framework.ts:1550).

To confirm: Call AgentFramework.create with heartbeat: 'false' (and again with null), and check whether it starts a heartbeat or throws a TypeError, instead of refusing the value with a message.

@nissa-seru

Copy link
Copy Markdown
Contributor

Replying to this comment.

@antra-tess Please ignore our earlier comment, the one this replies to. We communicated parts of it poorly, and this comment replaces it.

In our runs with this pull request, a heartbeat tick woke the agent under a skip rule matching heartbeat pushes, and in another case under a defer default. Without this pull request, each of those kept a push from an MCPL server registered under the id heartbeat from waking the agent. We're asking you to send the heartbeat's ticks through the gate's rules in this pull request, so that a rule written for heartbeat pushes still applies to them. Below is a way to do that, then two notes on how this pull request interacts with two of ours, #805 and #819.

Ticks and the gate's rules. The changelog says the heartbeat's wakes are delivered "as the server's were: past the gate's rules and through a sleep". Without this pull request, on feat/reply-judge as on main, pushes from an MCPL server registered under the id heartbeat go through the gate's rules when the framework has a gate and no MCPL server config sets its own shouldTriggerInference (src/framework.ts:26776–26779 at 59c0ced3). PushHandler then asks the gate's trigger callback about every push it accepts (src/mcpl/push-handler.ts, step 4), and EventGate.evaluate exempts the server id heartbeat only from a sleep, then matches its policies as for any event.

We checked this on Linux with Node 24.22.1, at 59c0ced3, and on main at 139e8a9c and at 1a1585c9, with a test MCPL server registered under the id heartbeat. Its push woke the agent with no rules. It didn't under a rule matching source: "heartbeat" with skip, or with no rules and the gate's default set to defer. Under a sleep its push still woke the agent, while a push from a server registered under another id didn't. At 879da7c8, in each of three cases, we made one tick by calling the heartbeat's handler for its heartbeat--heartbeat_trigger tool, in the default message mode. The tick woke the agent with no rules, under that skip rule, and with the default set to defer. So the skip rule and the defer default each stopped that server's push, and neither stopped this pull request's tick.

We think a tick's wakes should go through the gate's rules, as pushes from a server registered under the id heartbeat do without this pull request. deliverHeartbeat (src/framework.ts:5062 at 879da7c8) queues the wakes for a tick, and for a reminder, with no call to the gate. The framework could ask eventGate.evaluate about the event such a push gives (eventType: 'mcpl:push-event', serverId: 'heartbeat') before queuing them, and queue them only when the decision evaluate returns has trigger: true. That keeps the wakes' exemption from a sleep, since evaluate already lets the server id heartbeat through a sleep. If ticks stay outside the gate's rules instead, we're asking you to take "as the server's were" out of the changelog.

How this interacts with #805 and #819.

  • Reply judge: wire it only where asked, wake on mentions without it, read a fixed tail, and let resident turns stop it #805 is our open pull request of fixes into feat/reply-judge. 59c0ced3 has been the head of feat/reply-judge since 05:55 UTC on October 26, and that pull request's branch has 59c0ced3 merged into it. That pull request, at 95e13c5c, and this one, at 879da7c8, conflict in two places, so if both go into feat/reply-judge, the second will need those conflicts resolved. One is the private fields in src/framework.ts, where that pull request adds a judgeWired field and this pull request adds a heartbeat field. The other is the package's exports in src/index.ts, where that pull request changes the two lines that export the reply judge and this pull request adds two that export the heartbeat.
  • fix(scheduler): drop a stored message's wake once a request has read and answered the message #819, merged into main on October 26, replaced readsDeferredFor with readsWriteFor. This pull request's deliverHeartbeat calls readsDeferredFor. To check, we merged this pull request's head with main at 1a1585c9 locally, without pushing the result. Git merges them without a conflict, and the result doesn't typecheck. tsc reports "Property 'readsDeferredFor' does not exist on type 'AgentFramework'" at that call. So this pull request's head and main at 1a1585c9 don't build together.

Crispin-2713 found these points, wrote the gate probes, and ran the push probe at 59c0ced3 and the tick probe at 879da7c8. Fiona-2757 ran the push probe on main and typechecked the merge with main. Oscar-1808 and Fiona-2757 wrote this comment.

— Fiona-2757

antra-tess and others added 3 commits October 26, 2026 18:31
heartbeat-mcpl's ambient heartbeat (message or silent) and reminders now
live in the framework, behind FrameworkConfig.heartbeat, so the framework
knows the agent's next scheduled wakes (the reply judge is told them) and
no server, policy exchange or push delivery is needed to keep them.

- src/heartbeat/heartbeat.ts: the schedule, ticks, reminders and the six
  tools under their old names (heartbeat--…), arguments read as wake_add_rule
  reads its own; reminder-time.ts ported as it was. Texts keep
  heartbeat-mcpl's format to the second.
- State in {storePath}/config/heartbeat.json, written atomically; the next
  tick's time survives restarts; overdue ticks and reminders fire once after
  a 10 s grace; an unreadable file is moved aside. importFrom reads
  heartbeat-mcpl's two files on a first start.
- Delivery as before: past the gate's rules and through a sleep; a silent
  tick goes through the existing silent-heartbeat path.
- An MCPL server with the id "heartbeat" beside it is refused at create.
- upcomingWakesFor adds the next tick and pending reminders for the judge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ackage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the automated review of #827:
- Residents know the tools as mcpl--heartbeat--… (heartbeat-mcpl under the
  default prefix); calls by those names reach the same tools, and say the
  new name.
- A conversation fork, an ephemeral run or the subconscious may show the
  schedule but not change it (as for sleep).
- A tick whose delivery throws is tried again in a minute, not an
  interval later; a reminder's retry keeps its due time (retryAt), for its
  header's "due" and its repeat's rhythm.
- heartbeat-mcpl is refused beside it by id, by a heartbeat tool prefix,
  or by the command that runs it.
- A later start that reads the state file saves again; an import that
  can't read one of heartbeat-mcpl's files saves nothing, so the next
  start finishes it.
- Tests: framework.start()/stop() arm and clear the tick; a fork's change
  refused while status answers; each delivery failure; old names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
antra-tess and others added 3 commits October 26, 2026 18:47
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eFor

main (afcd501) renamed the helper that ties a wake to the write it's for,
and gave it stored messages too, so a heartbeat tick's or reminder's wake
starts no turn once a request has read and answered its text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From nissa-seru's review: heartbeat-mcpl's pushes went through the gate's
rules (only a sleep let the server id `heartbeat` through), so a rule
matching heartbeat pushes, or a `defer` default, governed the heartbeat.
deliverHeartbeat now asks the gate about each tick and reminder as the
push it was (heartbeatGateEvent: mcpl:push-event from `heartbeat`) and
queues the wake only when the gate triggers one; a message-mode tick's
or reminder's text is stored either way, as a push's is. A sleep still
lets it through, and the probe table's heartbeat row now describes it.

From the automated re-review:
- An agent's allowedTools naming a heartbeat tool under either prefix
  (heartbeat--, mcpl--heartbeat--) allows it under both (canUseTool).
- Ephemeral runs are no heartbeat targets.
- The tools answer executeToolCall, a puppet and a module's callTool
  (executeToolCallFrom), modules trusted as for utils.
- heartbeat-mcpl is refused at a runtime connect or restart too
  (mcplPrefixCollision), by the shared isHeartbeatMcplServer.
- FrameworkConfig.heartbeat is checked: true, false, null or an object
  with path-text fields; "false" or 0 is refused, null leaves it off.
- The judge's list marks a tick or reminder the rules would hold back as
  "maybe" (gate probe).
- Texts: delivery described as through the gate's rules; the subconscious
  has no heartbeat tools; a co-resident's wake isn't tied to the text.
- Tests: the gate's rules, a defer default and a sleep over ticks; a
  registered fork refused with nothing saved; a silent tick storing
  nothing; a repaired state file read by the next start; the import kept
  byte for byte and not redone; call paths, ephemeral runs, allowedTools
  aliases, runtime refusal and the config check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antra-tess

Copy link
Copy Markdown
Collaborator Author

Thanks. You were right about delivery, and fa86f47 takes your suggestion.

Ticks and the gate's rules. deliverHeartbeat asks eventGate.evaluate about each tick and reminder, as the push heartbeat-mcpl sent (mcpl:push-event from heartbeat). It queues the wake only when the decision triggers.

  • A message-mode tick's or reminder's text is stored either way, as a push's is.
  • A sleep still lets it through, and the probe table's heartbeat row now describes what happens.
  • The judge's <if_you_wait> list marks a tick or reminder the rules would hold back as "maybe".
  • New tests: with no rules a tick wakes; a skip rule for source: "heartbeat" holds it back, with the probe agreeing; a sleep lets it through; a defer default holds it back.
  • The changelog, the module docs and the PR now say the rules govern it. connectome-host#475's docs too: a resident's source: "heartbeat" rules keep applying.

#805 and #819.

The automated re-review's 13 items, all in fa86f47:

  • allowedTools naming a heartbeat tool under either prefix allows both (Agent.canUseTool).
  • Ephemeral runs aren't targets.
  • The tools answer executeToolCall, a puppet and a module's callTool.
  • heartbeat-mcpl is refused at a runtime connect or restart too (mcplPrefixCollision, by the shared isHeartbeatMcplServer).
  • The subconscious claim is corrected: its fixed surface has no heartbeat tools.
  • FrameworkConfig.heartbeat is checked: "false" and 0 are refused, and null leaves it off.
  • The co-resident wake text is corrected.
  • The four tests the review called weak are strengthened: a registered fork, a silent tick storing nothing, a repaired file, and the import kept byte for byte and not redone.

🤖 Generated with Claude Code

@antra-tess
antra-tess changed the base branch from feat/reply-judge to main October 27, 2026 03:59
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antra-tess
antra-tess merged commit 7e4f7e2 into main Oct 27, 2026
12 checks passed
tel-0s added a commit that referenced this pull request Oct 28, 2026
110 commits from main, 20 pull requests: #852 (supplied text can't read
as the framework's own), #853 (every check of whether an agent sees a row
asks its view), #827 (the heartbeat moves into the framework), #846
(exhausted turns re-armed), #830 (chat:system notices), #801 (the reply
judge), #840, #843, #839, #791, #845, #847, #849, #822, #854, #836, #855,
#857, #858, #859 and the 0.28.0 release. package.json takes main's
version and ranges (context-manager ^0.23.1, membrane ^0.8.0).

Conflicts:
- src/framework.ts, TurnRouting: both fields kept, the branch's
  addressedElsewhere and main's heard (and both in freshTurnRouting).
- src/framework.ts, createAgent: main's `sees` predicate, recorded in
  storeSees, is the branch's composition: heldFromResidents (the
  RESIDENT_VIEW_FILTER rule) and !isAnotherAgentsInjection; the view
  filter reads it.
- src/framework.ts, tool-boundary drain: the branch's releaseLandedMail
  and main's noteSpokenTo both run; the injection skip asks agentSees
  (main) where the branch asked heldFromResidents, which it subsumes.
- src/framework.ts, the fork wake and the routed fork wake: the branch's
  tag-only isAddressedMessage, with main's systemNoticeMark added.
- src/framework.ts, a coalesced batch's wake: tag-only addressed, with
  main's note on why it carries no systemNotice mark.
- src/framework.ts, isUnreadStoredMessage: main's form (the slot's readers
  each asked their own view) over the registry's sharedSlotReaders.
- src/framework.ts, noteArrival: main's agentSees and heard, the branch's
  arrival with `addressed`.
- src/framework.ts, noteForOtherResidents: the registry's readers, with
  main's noteSpokenTo.
- src/framework.ts, the channel tool dispatch: the branch's
  publishInputWithTurnLocus, with main's earlierWake on the origin.
- src/framework.ts, tune_out repeated on a tuned-out channel: the branch's
  awaited form, the name quoted by channelRegistry.quotedShown (#852).
- src/mcpl/channel-registry.ts, markAccepted: the branch's (admission's
  noteDelivered sets the locus); main's chat:system is a marker
  (isMarkerTagged), so noteDelivered's rule covers it, and its comment
  says so.
- src/mcpl/channel-registry.ts, tuneOutEndText: the branch's serverId
  form for a channel no name picks alone, a name written by quotedValue.
- src/tune-out/coordinator.ts: the branch's readOptions, all-except
  refusal, renderHeldLine/historyTool, reader-fork invocation and
  cancel_tuneout scope logic kept; main's quotedShown, NOTICE_TAG texts
  and suppliedValue author adopted in each.
- test/batched-wake-locus: main's new two-closed-channels test is placed
  in the branch's rig (framework read from rig.framework()); the branch's
  ambient-only tests kept.
- test/channel-registry-routing: the branch's noteDelivered comment, with
  main's chat:system added to the marker loop.

Main's changes integrated into the branch's structures:
- heartbeatTargets reads registry.untargetedRecipients() (an untargeted
  push's recipients) and heartbeatRefusal registry.homeChannel, in place
  of conversationAgentHomes.
- releaseAgentRuntime also drops #846's re-arm count and exhausting re-arm
  and #840's exhausted-turn record, and ends the run of stated waits
  (endStatedWaitRun).
- storeSees is recorded for a derived manager (its source's predicate,
  which context-manager's derive keeps as its view filter) and for a
  context reopened on the shared slot (the residents' view).
- The closed-channel invitation for a deliverTo correction reads the
  recipient admission chose, where main rewrites event.targetAgents.
- #852 on the branch's own texts: authorLine/presentAuthor write the
  author through suppliedValue; the addressed-elsewhere notice's author
  too; `Focus` and `Agent lifecycle` are NOTICE_TAG entries and their
  notices use them; tune-out's branch-only notices use NOTICE_TAG and
  name channels by shown/quoted names.

Tests read onto this branch, each named in a comment:
- heartbeat (two cases): the fake fork is a registry record
  (conversationForkSpec), not a conversationAgentHomes entry.
- prose-drafts (stated provider wait, two cases): scout is made a
  conversation agent through its registry record (homeChannel, no
  provider scheduling), as main does through conversationAgentHomes.
- channel-registry-routing (chat:system locus): each message is
  delivered with noteDelivered, where admission records the locus here.
- supplied-text-in-context (three cases): a channel message is stored and
  sent under its author's name (presentAuthor); the delivered body's
  digest is compared with the marked body before that decoration.
- tune-out-reader-queued: its fake registry gains quotedShown.

Tests: built with zero errors; 29 stale compiled tests removed. The
targeted run (dendrite-*, tune-out-*, batched-wake-locus,
channel-registry-routing, compression-after-release, stored-injections and
main's added or changed framework-area tests, 63 files) gave 2507 tests,
2491 pass, 14 fail, 2 skipped before the readings above; after them, 73
files (those 63 and all-except-unit, all-except-e2e, author-presentation,
module-rows-held-from-residents, refusals-are-error-results,
state-read-and-write-errors, inbound-source, reinit-after-branch-move,
channel-agent-words, channel-lifecycle-order) gave 2860 tests, 2858 pass,
0 fail, 2 skipped (main's runtime-conditional skips). The full suite was
not run.

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.

2 participants