Skip to content

feat(prose)!: a heartbeat turn holds its plain speech as drafts - #861

Merged
antra-tess merged 5 commits into
mainfrom
feat/heartbeat-prose-drafts
Oct 28, 2026
Merged

antra-tess merged 5 commits into
mainfrom
feat/heartbeat-prose-drafts

Conversation

@antra-tess

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

Copy link
Copy Markdown
Collaborator

A turn the heartbeat opens no longer posts its plain speech where the latest inbound message came from. Its words are held as drafts the agent can send where it chooses, in every routing mode.

Problem

A heartbeat tick or reminder wakes the agent with no channel. In locus and hybrid mode, such a turn froze its locus on the process-global fallback, the channel of the most recent inbound message, so the agent's check-in prose was posted wherever someone last spoke: a busy channel's ambient chatter, or a status line, decided where words meant for someone else went. #830 stopped chat:system notices from setting that fallback, but ordinary ambient messages still do. Nobody was waiting in that channel for the heartbeat's words.

Change

  • Which turns: a turn opened by a heartbeat wake (isHeartbeatWake): no channelId, no suppressProse, and source: 'heartbeat' (the framework's own heartbeat, ticks and reminders, or an MCPL server whose id is heartbeat), or a framework wake carrying InferenceRequest.heartbeatTurn. That mark goes on every framework wake that continues a heartbeat's turn with no locus: an autoContinue continuation, a bounce's resend wake, and a ! continuation. A re-arm copies its trigger and keeps the mark. A merged batch takes it when any cause is a heartbeat's wake and none names a channel, so the batch's order doesn't decide. A silent tick is left as it is. The turn's routing carries heartbeat: true, kept across its streams.
  • No locus, no fallback: the turn resolves no locus, and the active-channel resolver answers null for it, as for noChannel.
  • Locus and hybrid mode:
    • Unprefixed plain speech with no destination is held as a no-destination draft, mid-turn and at the turn's end, instead of failing at deliverSpeech.
    • Before the turn compiles, a [routing] notice tells it so, once per heartbeat run; a continuation isn't told again. The announced locus baseline is left alone, so the next channel turn doesn't re-announce its channel.
    • The mid-turn held notice gives the reason, and the [delivered] receipt names the drafts.
  • Explicit mode: unprefixed prose already bounced into a draft. The bounce of words with no destination requests no wake and doesn't count toward the bounce-wake cap. A >> target that fails still wakes the agent, as any bounce does.
  • autoContinue: a run that starts on a heartbeat's turn continues as configured. Each continuation is a heartbeat's turn too, and its [continue] row says its plain words are held.
  • What still reaches a channel: an explicit send, a >>/>>> destination, the agent's own channel_open, and a re-pin.
  • Unchanged: other channel-less wakes (timers, a host's own requests) keep the global fallback. So does a heartbeat that a gate rule batches (debounce/judge), which wakes as the gate's batch with source: 'gate'; the fragment says so.

Decisions for antra

  1. No wake when a heartbeat turn's words with no destination are held or bounced. They wait for the agent's next turn, where the receipt or the notice names them. A failed deliberate target still wakes, since it was meant for someone.
  2. Default, not a setting. The old behavior is the misroute class itself. An explicit send covers every case where the agent knows where it wants to speak, and a per-agent opt-out is easy to add if a resident needs it.
  3. A per-heartbeat [routing] row (locus and hybrid mode). It costs one short row per heartbeat, and in exchange the agent knows before it writes that its plain words will be held. The breaking fragment also suggests saying so in the heartbeat's own message.
  4. A gate-batched heartbeat isn't recognized (it wakes with source: 'gate'). Recognizing it needs the gate to mark a batch whose events are all heartbeat's; left out to keep this change small.

Tests

test/prose-drafts.test.ts, describe "a heartbeat turn holds its plain speech as drafts", 12 tests; all but the last use real turns against the speech-route MCPL fixture:

  • locus mode: held, named in the receipt, resendable, and the notice is given once with the baseline unchanged;
  • locus mode: mid-turn and closing prose held, and the mid-turn notice says why;
  • a tick fired through the framework's own heartbeat;
  • a heartbeat batched behind another channel-less wake;
  • hybrid mode: the resend wake after a failed >>> target holds its unprefixed words;
  • an explicit send still posts;
  • a non-heartbeat channel-less wake still posts (scope guard);
  • hybrid mode: unprefixed prose held, and >>> delivers;
  • an autoContinue continuation holds too, and its row says so;
  • explicit mode: a failed deliberate >> still wakes;
  • explicit mode: a quiet bounce, against a control that wakes;
  • a quiesce parks a heartbeat's wake apart from a later channel-less wake (checks the parked queue, no turn).

On the first revision, the four behavior tests then in the file failed with main's src/framework.ts, and the two guards passed. The tests added since came with their fixes and weren't mutation-checked again.

The first revision's full suite ran 8,608 tests: 8,563 passed, none failed, 45 were skipped. Later pushes ran only the affected files locally: prose-drafts, auto-continue, explicit-prose-routing, heartbeat, chat-system-notices, trunk-channel-routing, exhausted-turn-rearm, batched-wake-locus and batched-wake-line, 569 of 569 at 563e39b; for the quiesce fix, prose-drafts, auto-continue, busy-agent-scheduler and batched-wake-line/-locus, 339 of 339 at 3a24c47. CI runs the full suite: 12 of 12 legs green at 3a24c47.

  • Changelog fragment: changelog.d/heartbeat-prose-drafts.breaking.md

🤖 Generated with Claude Code

A turn the heartbeat opens (a tick or a reminder, from the framework's
heartbeat or an MCPL server whose id is `heartbeat`; not a silent tick) no
longer posts its plain speech where the latest inbound message came from.
It has no locus and takes no fallback, in every routing mode:
- locus and hybrid: unprefixed speech with no destination of its own is
  held as a `no-destination` draft, named mid-turn by a notice that says
  why, and at the end by the `[delivered]` receipt;
- explicit: its bounce is noticed but requests no wake, and doesn't count
  toward the bounce-wake cap.
An explicit send, a `>>`/`>>>` destination, the agent's channel_open and a
re-pin still reach a channel. Other channel-less wakes route as before.

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

@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, then merge. 1 high severity finding should be fixed first.

Reviewed commit c47629b 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. It narrows how the framework's existing prose routing treats heartbeat-opened turns, and it reuses machinery the framework already has (locus, noChannel, no-destination drafts, bounce-wake cap) inside src/framework.ts, the component that owns all of it.

5 reasons with sources

It is a breaking default change, and it ships with the breaking fragment and migration note the project asks for.

  • The change stays inside the prose-routing behavior the framework already owns. It doesn't add a new feature area. TurnRouting already carries noChannel and a frozen locus, and plain speech with nowhere to go is already held as no-destination drafts. The PR adds a heartbeat flag next to noChannel. It routes through the existing holdProseDrafts and the bounce-wake logic rather than building a second mechanism. (src/framework.ts (TurnRouting, routeSpeech/holdProseDrafts paths); README.md, Streaming Lifecycle)
  • It reuses existing concepts rather than adding a second way to hold or resend speech. Held words go through the existing drafts resend path and [delivered] receipt. The resolver answers null the same way it already does for noChannel. No new dependency, setting or public API is added. (src/framework.ts (holdProseDrafts, PROSE_BOUNCE_WAKE_CAP, activeChannelResolver))
  • It is a fleet-wide breaking default, and it is documented the way the project requires. The .breaking.md fragment opens by naming who needs to act ("Residents and hosts whose agents answer a heartbeat…") and gives a migration path, as changelog.d/README.md asks. Whether to add an opt-out is left for the maintainer to decide (Decision 2). The project states no policy against changing this default. (CONTRIBUTING.md, Changelog; changelog.d/README.md)
  • The PR body follows the house shape and evidence conventions. It has Problem / Change / Tests sections, a numeric test baseline, a mutation check showing the behavior tests fail on main, scope notes (the gate-batched heartbeat is out of scope) and the AI footer. (CONTRIBUTING.md, What a PR should contain)
  • It is one coherent change, with tests and a fragment and no unrelated work bundled in. The diff touches only heartbeat-turn routing in framework.ts, the prose-drafts tests and the fragment. Gaps the code review reported, such as the autoContinue continuation, are defects for that review to handle. They don't put the change outside the project's scope. (CONTRIBUTING.md, What a PR should contain)

Findings

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

1 finding about lines the diff does not show:

  • Medium severity. The sleep announcement from a heartbeat turn now fails with a misleading route-failure notice. src/framework.ts:33344-33349
Details of this finding
  • Medium severity, src/framework.ts:33344-33349. The sleep announcement from a heartbeat turn now fails with a misleading route-failure notice. In locus and hybrid mode the sleep tool announces to callingTurn.locus ?? null. A heartbeat turn now has no locus, so registry.routeSpeech(agentName, announcement(null), null) reaches deliverSpeech's no-locus branch. That branch fires onRouteFailure, telling the agent 'this turn came from no channel, and it had no home or recent channel to reply in; if it should have had one, ask whoever runs this host'. Before this change the announcement went to the fallback channel. The PR did not route this speech path through routeLocusSpeech or skip it for heartbeat turns, so a heartbeat check-in that ends in sleep now produces a spurious failure marker that blames the host.
3 more findings, less severe
  • Low severity, documentation, src/framework.ts:20925-20961. Turn-open comments still say heartbeat turns fall back to the global default. The comment above routing.triggerChannel says a 'heartbeat / no-trigger turn ... falls back to the global default'. The locus comment says 'The eager snapshot protects heartbeat turns from a moving defaultPublishChannel'. Both are now false: a heartbeat turn takes no fallback. channel-registry.ts buildChannelContext (line ~2452) likewise still says 'else the global default (heartbeats)', and TurnRouting.locus (1302-1303) does not mention the heartbeat case. These comments now contradict the new behavior.
  • Low severity, maintainability, src/framework.ts:18034-18051. Heartbeat holds are made one segment at a time, so each segment gets its own held notice and log line. routeLocusSpeech calls holdHeartbeatProse(agent, [text], round) once per segment, from enqueueSpeech and from the text-only and trailing loops. Each call writes its own [drafts] held notice and console line. Other hold paths, such as holdSilencedProse and routeSilencedHybrid, hold all of a round's segments in a single holdProseDrafts call, so a round gets one notice. A multi-paragraph heartbeat round will add N notices to the context. Batching the round's segments would match the existing pattern.
  • Low severity, test coverage, test/prose-drafts.test.ts:521-530. Heartbeat wakes in the tests are synthesized by hand, so the real heartbeat and heartbeat-mcpl paths are untested. The harness pushes { reason: 'heartbeat', source: 'heartbeat' } straight into pendingRequests. The framework's deliverHeartbeat (reason = wake.kind, through the gate) and the MCPL push path (source: event.serverId for a server id 'heartbeat') are never exercised. A change in either producer's request shape would silently put heartbeat turns back on the global fallback without any test failing.

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

Comment thread src/framework.ts Resolved
Comment thread test/prose-drafts.test.ts Resolved
Comment thread src/framework.ts Resolved
Comment thread test/prose-drafts.test.ts Resolved
Comment thread src/framework.ts Resolved
@nissa-seru

Copy link
Copy Markdown
Contributor

@antra-tess At c47629b9cf, whether a turn's plain speech is held depends on queue order: a timer's words are held when a heartbeat request, a silent tick included, was queued ahead of the timer's request in the same batch. The description says "other channel-less wakes (timers, a host's own requests, gate batches) keep the global fallback" and "A silent tick is left as it is". Below are that run, a 0.28.0 changelog sentence that no longer describes a heartbeat turn, and what a resident meets around a heartbeat turn, with two changes we'd make.

The batch case. We ran each case as a turn after a message in a room. Where a row has two requests, both were queued in one tick, so they make one batch. source: 'test' stands for a timer or a host's request, as the PR's own scope test uses it. Each run in this section was on Linux with Node 24.22.1, context-manager 0.23.1 and membrane 0.8.0.

batch 65bf22bc0a, this PR's base c47629b9cf
tick alone posted to the room held as a no-destination draft
other wake alone posted posted
tick, then other wake posted held
other wake, then tick posted posted
silent tick, then other wake posted held

src/framework.ts:16935 takes the turn's trigger from requests[0] when no request resumes a turn or restarts a budget, and isHeartbeatWake (:680-682) reads the trigger's source, channelId and suppressProse. :17043 keeps suppressProse only when every request is silent. We'd decide it the same way: a batch is a heartbeat turn only when every request in it is a heartbeat tick or reminder with no channel, silent ones included. We ran c47629b9cf with the merged request carrying requests.every((r) => r.source === 'heartbeat' && !r.channelId) and isHeartbeatWake requiring it. All three mixed batches above then posted, as at 65bf22bc0a. A tick alone, a tick then a silent tick, and a reminder then a tick stayed held, and the PR's own six heartbeat tests passed. What it costs: in a mixed batch, the heartbeat's own words go to the global fallback again, since the turn also answers another wake.

A released sentence. 0.28.0's CHANGELOG.md:648 says that a turn a chat:system notice wakes alone "resolves its locus as a heartbeat does". The table's first row shows a heartbeat turn no longer posting to the latest channel, so that sentence no longer describes a heartbeat turn, and the fragment doesn't mention it.

What a resident meets. We ran heartbeat turns at c47629b9cf with the PR's own test harness and a probe that records what the agent's context gains, on Linux with Node 24.22.1, context-manager 0.23.1 and membrane 0.8.0.

  • Before it speaks, nothing in the turn said plain text would be held. The default tick's message (src/heartbeat/heartbeat.ts:84) says "act if needed; otherwise a brief note to yourself is fine", and skip_reply's description (src/mcpl/channel-registry.ts:617) says "To reply instead, just write plain text (no tool call)."
  • Text before a tool call got a notice before the turn's next round, saying why it was held ("because this turn came from your heartbeat, not from a channel, …") and how to send it with drafts. In explicit mode the bounce said the turn had no destination and gave the drafts syntax, with one model call and no wake. Both tell the agent what it needs.
  • Text as the turn's closing words posted nothing, and besides those words the agent's context gained only [delivered] nothing — 1 plain-speech segment(s) held as draft d-k5fa676 (not sent — drafts can resend them unchanged, or dismiss them). It doesn't say why or when the draft was held, and it names drafts without the call's form that the mid-turn notice gives. With no wake, the agent read it at the next room message, beside that message.

Decision 3 says the held-draft notice tells the agent the first time it happens. When the held text is the turn's closing words, the receipt above is all the agent gets. We'd give the closing receipt the mid-turn notice's "because" and the time the draft was held. And since the default tick's message is the framework's own text, we'd have it say that a heartbeat turn's plain text is held. It's already sent every tick, so saying it there adds no message of its own.

Rupert-2634 read the change and ran the batch cases. Imogen-2490 ran heartbeat turns as a resident meets them. Fiona-2757 wrote this comment.

— Fiona-2757

antra-tess and others added 2 commits October 27, 2026 17:36
…, and a notice up front

- An autoContinue continuation of a heartbeat's turn carries the turn's
  mark (AutoContinueRun.heartbeat), read before the start drops the mark,
  so it holds its plain speech too instead of taking the global fallback;
  its [continue] row says its words are held.
- Only the bounce of words with no destination is quiet: a `>>`/`>>>`
  target that fails was meant for someone, and wakes the agent as before.
- sleep from a heartbeat's turn with no locus posts no announcement,
  rather than a route failure that blames the host.
- A heartbeat's turn is told up front, once per heartbeat run, that its
  plain speech is held, without moving the announced locus baseline.
- Comments that said a heartbeat's turn takes the global default.
- Tests: a tick from the framework's own heartbeat; an autoContinue
  continuation; a deliberate `>>` bounce still waking; the up-front notice
  and the unchanged baseline.

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

# Conflicts:
#	src/framework.ts
#	test/prose-drafts.test.ts
@antra-tess

Copy link
Copy Markdown
Collaborator Author

Review findings outside the inline threads:

  • sleep announcement from a heartbeat turn (medium): fixed in aafec67. A heartbeat's turn with no locus posts no sleep announcement (a log line says why), rather than a route failure that blames the host. I dropped its end-to-end test: the speech-route harness hangs on a sleep call whether or not the turn is a heartbeat's (a plain channel-less turn hangs the same way), so it can't hold that test.
  • Stale comments (low): fixed in aafec67. The turn-open comments, TurnRouting.locus, the active-channel resolver, buildChannelContext and InferenceRequest.channelId now say a heartbeat's turn takes no fallback.
  • Real heartbeat producer untested (low): fixed in aafec67. New test: a tick fired through the framework's own heartbeat (heartbeat--heartbeat_trigger) opens such a turn and its words are held.
  • One held notice per segment (low): declined. A round's plain speech is normally one text block, so one segment and one notice. Several segments in one round is rare, and the cost is a few extra notice lines.

Also in aafec67: the [continue] row of a heartbeat run says its words are held, where it said "sent as usual". Merged main at f14e7fb (d1ff424); the up-front notice uses NOTICE_TAG.routing.

Affected files (prose-drafts, auto-continue, explicit-prose-routing, heartbeat) on d1ff424: 442 of 442 pass. CI runs the full suite.

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

Reviewed commit d1ff424 by reading the code, in four stages: a plan by claude-opus-5-5; two readers on every task of it, gpt-6.1-terra 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 (384 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 (7)

Minor (7)

7 minor issues, by file
  • Heartbeat continuation notice overstates the hold, saying all unskipped writing is held even though deliberate envelopes publish. src/auto-continue.ts:46, src/framework.ts:19863, src/framework.ts:20024 The heartbeat continuation row (src/auto-continue.ts:46) says that anything written without skip_reply is held as a draft. By reading, a hybrid >>>#room envelope or an explicit-mode >> target written in that continuation is still published, so the row overstates the hold for deliberate envelopes.
  • Heartbeat notice is unconditional while a fork's home channel still supplies the locus, so held-speech notice contradicts actual posting. src/framework.ts:21200, src/framework.ts:21209, src/framework.ts:18484 and 12 more places A heartbeat push from an MCPL server heartbeat that targets a conversation fork gets its home as locus (channel-registry.ts:5682), so its plain speech posts there: the holds at src/framework.ts:18234 and :19927 need locus === null. Yet the notice at src/framework.ts:21209/18484 says that speech isn't posted. TurnRouting.heartbeat's "takes no locus" (src/framework.ts:1341) also leaves out forks.
  • Stale doc comments still describe heartbeat channel resolution as falling back to the global default channel. src/framework.ts:17156, src/framework.ts:17160, src/types/agent.ts:635 and 4 more places Doc comments still say a heartbeat turn takes the global fallback: src/mcpl/channel-registry.ts:1053 and :1066-1068 (resolver "resolves to undefined" for a heartbeat), src/framework.ts:17156 and :17160-17161, and src/types/agent.ts:635. At src/framework.ts:29046 the resolver now returns null for a heartbeat turn, so a maintainer following these comments would expect the removed fallback.
  • announceLocusIfChanged's doc comment now sits above noticeHeartbeatTurn, leaving announceLocusIfChanged undocumented. src/framework.ts:18473, src/framework.ts:18494, src/framework.ts:18472 and 5 more places The new noticeHeartbeatTurn was inserted between announceLocusIfChanged's doc comment and that method. The announce-on-change block (src/framework.ts:18464-18473) now sits orphaned above noticeHeartbeatTurn's own doc (src/framework.ts:18474), and announceLocusIfChanged (src/framework.ts:18494) has no doc of its own. An IDE hover or anyone reading the code will match the comment to the wrong method.
  • Mid-turn routing log names '(default)' for heartbeat segments that are held as drafts. src/framework.ts:22496 In a heartbeat turn roundLocus is null, so the mid-turn log at src/framework.ts:22496 says the segments go 'live -> (default)'. routeLocusSpeech (src/framework.ts:18234) actually holds them as no-destination drafts. Anyone reading the logs is told the words went to a default channel when nothing was posted.
  • Mid-turn heartbeat prose segments each trigger a separate held-draft notice. src/framework.ts:22499, src/framework.ts:18234, src/framework.ts:18242 and 1 more place In locus mode, a heartbeat round with several prose runs holds each segment separately (src/framework.ts:22499 → 18235). Each hold posts its own 'prose-drafts' notice (src/framework.ts:17548), each repeating the heartbeat explanation. The silenced-hold path at src/framework.ts:22489 instead batches the round's runs into one hold. The maintainer declined this as rare. Already raised on this pull request.
  • Change text's mutation-check result and test list do not match the suite's actual tests. test/prose-drafts.test.ts:4387, test/prose-drafts.test.ts:4322, test/prose-drafts.test.ts:4304 and 1 more place The description says "with src/framework.ts from main, the four behavior tests fail and the two guards (explicit send, non-heartbeat fallback) pass", and lists six tests. The describe at test/prose-drafts.test.ts:4248 now has nine. By reading, the tick (4303) and autoContinue (4363) tests would also fail on main. The deliberate-'>>' test (4387) would pass there, so it's a guard the description doesn't mention.

In existing code (1)

1 issue in code that was there before this change, which doesn't weigh in the recommendation
  • Minor. Heartbeat flag persists after settlement, so activation previews between turns omit the fallback incoming and default channels. src/framework.ts:29046, src/framework.ts:10371, src/framework.ts:9503 and 4 more places By reading, after a heartbeat turn settles its routing entry keeps heartbeat, so the resolver at src/framework.ts:29046 answers null between turns. An injection-enabled previewActivation (src/framework.ts:9500-9503) then gives beforeInference hooks no incoming or default outgoing channel, though a fresh channel-less activation would use the global fallback. Other stale turn routing already does this between turns.

For a review with Sol in place of Terra as one of its two readers, a maintainer can comment /review deep.

Comment thread src/framework.ts Resolved
Comment thread src/framework.ts Resolved
Comment thread src/framework.ts Resolved
Comment thread test/prose-drafts.test.ts Resolved
…from

Re-review of #861:
- InferenceRequest.heartbeatTurn (framework-set) replaces
  AutoContinueRun.heartbeat. A framework wake that continues a heartbeat's
  turn with no locus carries it: an autoContinue continuation, a bounce's
  resend wake, a ` !` continuation; a re-arm copies its trigger, which
  keeps it (only autoContinueRun is dropped at the start). A merged batch
  takes it when any cause is a heartbeat's wake and none names a channel,
  so the batch's order no longer decides; false otherwise, so a silent
  tick first in a batch doesn't make it one.
- The [continue] row of a heartbeat run says plain words with no
  destination are held; the mid-turn log says held, not '(default)';
  announceLocusIfChanged gets its doc comment back; two more comments.
- The fragment says what still wakes (a failed >>/>>> target, an
  autoContinue run), that the up-front notice is per heartbeat in locus and
  hybrid mode, and that a gate-batched heartbeat routes as before.
- Tests: a heartbeat batched behind another channel-less wake; the hybrid
  resend wake after a heartbeat turn's failed >>> target.

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

Copy link
Copy Markdown
Collaborator Author

Re-review (d1ff424), items outside the threads, at 563e39b:

  • Batch classification by queue order (should fix): fixed. A merged batch is a heartbeat's turn when any cause is a heartbeat's wake and none names a channel, and isn't one otherwise, even with a silent tick first (heartbeatTurn on the merged trigger). Test: "a heartbeat batched behind another channel-less wake is still a heartbeat's turn".
  • Gate-batched heartbeats (should fix): the fragment now says such a heartbeat wakes as the gate's batch and routes as before. Recognizing gate batches stays out of scope (Decision 4).
  • Minor, fixed: the [continue] row says plain words with no destination are held, since deliberate envelopes still publish. announceLocusIfChanged's doc comment is back on it. The mid-turn log no longer says (default) for held segments. Two more comments are corrected. The description's test list and suite numbers now match.
  • Minor, declined:
    • The notice while a fork's home channel supplies the locus: unreachable, since the heartbeat doesn't wake conversation forks (heartbeatTargets), and a heartbeat-mcpl push's default broadcast excludes them.
    • One notice per segment: a round's speech is normally one text block.
    • The routing flag persisting into previews between turns: this only affects the reply judge's preview of the channel line until the next turn re-resolves its locus, and the next turn does.

🤖 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: fix here or file an issue for each, then merge. 2 should-fix issues should be fixed in this pull request or filed as an issue before merging. It covers only what changed since d1ff424, the commit an earlier review read; that review's issues stand as it gave them.

Reviewed commit 563e39b, reading only what changed since d1ff424, the commit the earlier review re-anima-research.agent-framework-861-1 read, by reading the code, in four stages: a plan by claude-opus-5-5; two readers on every task of it, gpt-6.1-terra 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 (146 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 (2)

For a review with Sol in place of Terra as one of its two readers, a maintainer can comment /review deep.

Comment thread src/framework.ts Resolved
Comment thread src/framework.ts Resolved
Third review of #861: while the host is quiesced, parked wakes coalesce
per key, and a heartbeat-mcpl push shared its key with a later
channel-less push from another server, which absorbed it, so the resumed
turn had no heartbeat cause and took the global fallback. The key now
tells a heartbeat's wake apart; resumed together, the two make a
heartbeat's batch, as they would without the quiesce.

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

@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. The review found no blocker or should-fix issue in the change that its judge held real. It covers only what changed since 563e39b, the commit an earlier review read; that review's issues stand as it gave them.

Reviewed commit 3a24c47, reading only what changed since 563e39b, the commit the earlier review re-anima-research.agent-framework-861-2 read, by reading the code, in four stages: a plan by claude-opus-5-5; two readers on every task of it, gpt-6.1-terra 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 (29 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.

Minor (5)

5 minor issues, by file
  • Change text says heartbeat speech is held as drafts in every routing mode, but disabled mode keeps it only in the transcript. changelog.d/heartbeat-prose-drafts.breaking.md:6, src/framework.ts:22450, src/framework.ts:23112 and 3 more places The description says a heartbeat turn's words "are held as drafts the agent can send where it chooses, in every routing mode", but in proseRouting 'disabled' plain speech is only suppressed and no draft is held (src/framework.ts:22450, src/framework.ts:23112), as test/prose-drafts.test.ts:754 asserts. changelog.d/heartbeat-prose-drafts.breaking.md:6-7 reads the same way.
  • The no-locus claim ignores a heartbeat wake targeting a conversation fork, whose home channel outranks the heartbeat's null resolver. changelog.d/heartbeat-prose-drafts.breaking.md:5, src/framework.ts:16644, src/framework.ts:21213 and 1 more place By reading: a push from a server named heartbeat whose targetAgents names a conversation fork marks the fork's turn as a heartbeat's (src/framework.ts:21194). resolveLocus still returns the fork's home (src/mcpl/channel-registry.ts:5683), so its plain speech is posted instead of held (src/framework.ts:18244). Yet the fork still gets the "isn't posted" notice (src/framework.ts:21222), and the fragment's "no channel" claim (changelog.d/heartbeat-prose-drafts.breaking.md:5) is wrong for this case. Already raised on this pull request.
  • continuesHeartbeat keys on source 'framework', so a re-armed heartbeat tick is not a continuation and gets a second routing notice. src/framework.ts:21035, src/framework.ts:21223, src/framework.ts:6076 and 1 more place By reading, when a heartbeat tick's turn exhausts its retries, rearmExhaustedTurn keeps the tick's source 'heartbeat' (src/framework.ts:6076). continuesHeartbeat requires source 'framework' (src/framework.ts:21035), so the re-armed turn appends a second [routing] notice (src/framework.ts:21223) for the same heartbeat. The description says the notice comes "once per heartbeat run; a continuation isn't told again.".
  • Comments say channel-less heartbeats take the global fallback, but the heartbeat-specific active-channel resolver returns null. src/framework.ts:16745, src/mcpl/channel-registry.ts:1065, src/framework.ts:29057 The activeChannelResolver doc at src/mcpl/channel-registry.ts:1066-1068 says a heartbeat turn resolves to undefined and takes the global fallback. derivePushEventChannel's doc at src/framework.ts:16745-16746 says heartbeats keep the global fallback. At head the resolver returns null for a heartbeat's turn (src/framework.ts:29059), and that turn takes no fallback.
  • Change text's Tests section is stale: it lists 11 real-turn tests and an older run, omitting the added twelfth quiesce test. test/prose-drafts.test.ts:4356, test/prose-drafts.test.ts:4248 The description says the heartbeat describe block has "11 tests with real turns" and lists 11. The block now has 12: the quiesce test at test/prose-drafts.test.ts:4356 isn't listed, and it runs no turn, since it only checks the parked queue while quiesced. The reported 569/569 run is at 563e39b, before this test and the quiesce fix were added.

In existing code (3)

3 issues in code that was there before this change, which don't weigh in the recommendation
  • Should fix. Parked-wake key gives silent heartbeat ticks no heartbeat discriminator, so they share keys with ordinary wakes and suppress them on resume. src/framework.ts:16953, src/framework.ts:691, src/framework.ts:17216 and 9 more places By reading, a silent heartbeat tick is not isHeartbeatWake (src/framework.ts:691), so its parked-wake key at src/framework.ts:16953 matches an ordinary push wake of the same reason and addressed flag that has no read ids (a co-resident's). If the tick is newer it replaces that wake (16955), dropping its channel, and the resumed batch becomes silentOnly (17216): a genuine wake's turn runs silent with no channel. This was already the case at the base. To confirm: With the host quiesced, park for a co-resident an unaddressed channel-bearing 'mcpl:push-event' wake and then a newer silent tick (suppressProse true, same reason). Run processInferenceRequests and check that only the tick stays in pendingRequests; after resume, check that the turn is suppressProse with no channelId.
  • Minor. Parked-wake key omits the channel, so a channel-less wake with the same reason and addressed flag evicts a channel-bearing wake. src/framework.ts:16953, src/framework.ts:16955, src/framework.ts:17189 and 10 more places By reading, the parked-wake key at src/framework.ts:16953 has no channel part. A newer channel-less 'mcpl:push-event' wake replaces a co-resident's channel-bearing one that has the same addressed flag and no read ids (16955). The resumed batch then has no channelReq (17190), so it takes the global fallback, or, with a heartbeat parked, holds its words as drafts (17247). This was already the case at the base.
  • Minor. Provider-hold deduplication ignores suppressProse and heartbeatTurn, so it can drop a heartbeat cause as a duplicate of a silent tick. src/framework.ts:5944, src/framework.ts:5956, src/framework.ts:6362 and 2 more places By reading, the provider-hold dedup in sameInferenceRequest (src/framework.ts:5944) doesn't compare suppressProse. A co-resident's silent heartbeat tick and its normal heartbeat push, if stamped in the same millisecond, carry no read marks (src/framework.ts:28077), so holdRequest (src/framework.ts:5956) drops whichever comes second. If the silent tick came first, the released batch is silent-only (src/framework.ts:17243) and is no heartbeat turn whose prose is held as drafts.

For a review with Sol in place of Terra as one of its two readers, a maintainer can comment /review deep.

@antra-tess

Copy link
Copy Markdown
Collaborator Author

Final review (3a24c47): merge recommended. On its minor items:

  • Description's test list: updated (12 tests, current runs).
  • Declined, with reasons:
    • Disabled mode: a disabled agent's prose is never routed, so it has nothing to hold.
    • A heartbeat push aimed at a conversation fork: the heartbeat doesn't target forks, and a push's default broadcast excludes them.
    • A re-armed tick's second [routing] notice: one extra row in a rare re-arm.
    • Comment wording at channel-registry.ts:1066 and two framework comments: comment-only; the contract is stated on TurnRouting.heartbeat and InferenceRequest.heartbeatTurn.
  • The three "in existing code" findings predate this PR and are filed as Parked-wake coalescing and provider-hold dedup ignore silence and channel #865.

🤖 Generated with Claude Code

@antra-tess
antra-tess merged commit 921390c into main Oct 28, 2026
12 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 28, 2026
0.29.0 folded changelog.d/838-rearm-exhausted-turns.changed.md into
CHANGELOG.md, and this branch had edited that fragment to say a marked
deadline in a re-armed turn's send counts as a transient failure once. The
fragment is gone, and the addition moves to its own fragment,
changelog.d/871-rearm-deadline-cut.changed.md, written against 0.29.0's
released entry, which listed every deadline membrane marks as run out among
what the re-arm leaves unchanged: the new fragment says that still holds for
a turn that isn't itself a re-arm.

src/framework.ts merged without conflict: main's heartbeat drafts (anima-research#861)
and this branch's deadline rule change different hunks. The
exhaustedTurnRearmMs doc in src/types/framework.ts and
test/exhausted-turn-rearm.test.ts are this branch's alone; main changed
neither.
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 28, 2026
main merged anima-research#861, which gives a heartbeat's turn no channel and holds its
plain speech as drafts, and then released 0.29.0. anima-research#861 conflicted in eight
blocks: six in src/framework.ts, one in src/types/agent.ts and one in
test/prose-drafts.test.ts. Each keeps both sides, and where the two rules
meet, a heartbeat's turn that takes in private input, this branch's rule
holds: a private turn's words are kept private and never drafted.
- TurnRouting keeps anima-research#861's heartbeat field beside this branch's locus
  wording. Its doc no longer says a heartbeat turn's plain speech is held
  as a draft in every routing mode, which a private heartbeat turn makes
  false.
- A quiesce's key keeps this branch's mark for a private wake and anima-research#861's
  for a heartbeat's.
- A ' !' continuation and a bounce's wake carry the turn they continue, as
  this branch's derived requests do, and anima-research#861's heartbeat mark, as main's
  auto-continuation already does after the merge.
- At a start, anima-research#861's notice that a heartbeat turn's words are held as drafts
  goes only to a heartbeat turn that isn't private from that start. One
  that is gets what any private turn's start gets: the private-turn notice
  when stored private input made it private, or, opened by a private wake,
  the locus announcement of no channel when that changes.
- sleep's announcement isn't posted in a private turn, as before, or in a
  heartbeat's turn with no channel, as on main.
- InferenceRequest.channelId's doc names both: a heartbeat's turn holds its
  plain speech as drafts unless private input makes it private, and a
  private one takes no channel.
- prose-drafts' turn helper takes both 'private' and 'heartbeat'.

Every place that delivers plain speech already checks a private turn
before anima-research#861's heartbeat hold, so a heartbeat turn that holds private input
keeps its words private there with no further change.
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 28, 2026
…ivate

Since the merge of main's anima-research#861, a heartbeat's turn holds its plain speech
as drafts. Where such a turn takes in private input, this branch's rule
holds instead: a private turn's words are kept private and never drafted,
since a draft is one resend away from a channel.

Tests (prose-drafts), in locus, hybrid and explicit mode:
- a heartbeat batched with an operator's private wake, and one whose start
  reads an operator's stored message, keep their words private, with no
  draft and no notice that they are held; beside them, a heartbeat with no
  private input holds its words as a draft, and in locus and hybrid mode is
  told so, as on main;
- a heartbeat turn that an operator's message makes private at a tool
  boundary keeps the words after it private, with no draft; beside it, a
  heartbeat turn with no private input holds its words before and after the
  call as drafts, as on main.

The changed fragment says a heartbeat's turn that takes in private input
is private too, where 0.29.0 holds a heartbeat turn's words as drafts.
tel-0s added a commit that referenced this pull request Oct 28, 2026
… the house's fixes

feat/dendrite at 2fbff64 is 138 commits past this branch's twelfth-round
head (da0e7a3): main's 0.29.0 by 6e70480 (#860, the host's own toolsets:
a built-in "settings" toolset in place of utils, MCPL server tabs,
Module.toolPlacement, presentedTools; #861, a heartbeat's turn holds its
plain speech as drafts; the release, which folded its fragments into
CHANGELOG.md), and the house's review fixes on #546 (reader forks act only
through their own epoch's hold and call only their own tools, a late fork
for a failed reader past its epoch, the tally of unread held messages
rebuilt at boot, the all-except record read and released in a safe order,
reparentAgent on an ended orphan, resumeAgent asking beforeDerive, one
resume at a time, the registry's recovery-file and ended-log fixes, the
routing baseline kept across a restart, channel_publish's default from
the calling turn, early traces capped with dendrite:early-traces-dropped).
package.json takes the house side's 0.29.0; this branch's floors stand
(chronicle ^0.5.0, context-manager ^0.24.0, membrane ^0.8.0), which
test/dependency-ranges pins.

Seven paths conflicted (31 regions):

- src/framework.ts (12):
  - fields: the house's ended-log index and namespace owners beside this
    branch's hostModePath comment (the deferred-writes path is the
    importer's, documented below it).
  - deriveAgent (2): the house's try/discardEphemeralCandidate around the
    derivation, with this branch's storeOwnRow for the copy's inheritance
    and framing (the child's own rows).
  - resumeAgent: the house's form (spec built first and asked of
    beforeDerive, resumesPending, the run's membrane, a cleanup that
    releases the manager), with this branch's ownSlot kept in the spec.
  - reopenAgentContext (2): this branch's three arms, each recording its
    view (recordView), with the house's membrane parameter in all three;
    the house's residentSees/derivedSees/seen helpers stay out, as in the
    twelfth round.
  - lifecycleNotice: the house's tellResultHeld added; this branch's doc
    (the detail names its `to`).
  - reconcileAgentRegistry: the house's settleInterrupted after a sync and
    deliverHeldMail; deliverHeldMail keeps this branch's isMailCopyFor and
    importLeftQueued checks where the house read the deferred queue.
  - the quiesce's park key: this branch's readsRow with the house's
    heartbeat 'h' part.
  - repinTurnLocus: storeOwnRow with the house's rememberAnnouncedLocus.
  - addressedElsewhereNotice: storeOwnRow as an own row, with the house's
    add-after-store and landed-though-threw read-back; a landed row is
    presented by its id (ownRowsStored), as the clock's and [continue]'s
    are, so it rides the boundary's injection.
  - the [continue] gate: this branch's (no sharedSlotBusy, which went with
    the deferral queue) with the house's heartbeat flag.
- src/tune-out/coordinator.ts (9): the house's per-epoch hand-offs
  (handOffKey, dropHandOffs), late fork, last looks and tally rebuild,
  scopeReleased and record order, on this branch's stored-only shape: no
  queued half (readerHandedWrites, writeIds, queuedHeld,
  getDeferredMessages), readerReturned a set of ids per epoch and
  channel, a late fork handed the stored rows its failed fork was. The
  boot's tally rebuild runs after this branch's window-start resume
  (pruneWindowStarts, anchorSubconscious) and reads stored rows only;
  cancel and cancelAllExcept keep forgetWindowStart beside the house's
  dropLooks/dropHandOffs/rebuildTally; cancelAllExcept's dump keeps
  residentMessages (a surgery's held writes) with the house's
  server-and-channel grouping.
- docs/dendrite.md (1): gaps 6 and 7 as this branch states them (stored
  at once), with the house's gap 9 (a tuned-out channel's close refused)
  and the gap-11 test location.
- test/all-except-unit (3): this branch's harness ({ stored, kept }) with
  the house's fail switches and its six new cases; its two-servers case
  calls harness({ stored }).
- test/dendrite-lifecycle (1): the house's full deepEqual of the
  derivation notice's detail, with this branch's `to: 'oren'`.
- test/tune-out-reader-backlog (4, the house's tune-out-reader-queued by
  rename): this branch's queue-less world with the house's restart,
  looks, unreadable records and newest-row currentSequence; the house's
  new cases read onto stored rows (bodies are arrays); the late-fork case
  holds two stored rows; the two write-id cases stay deleted, and the two
  queue-only cadence cases are left out with a comment (every held
  message is stored).
- changelog.d/context-manager-floor-0.23.1.changed.md (modify/delete):
  deleted, as 0.29.0 released it.

Read onto this branch past the conflicts:
- noticeHeartbeatTurn (main's #861) writes its [routing] notice through
  storeOwnRow, as the agent's own row, presented as stored before the
  heartbeat turn's compile (the locus announcement's place).
- the restart notice's dedup counts an earlier notice only where it was
  for the recipient (its dendrite.to, which this branch's lifecycle
  notices carry): in the residents' shared slot the store holds every
  resident's notices; it reads importLeftQueued where the house read the
  restored deferred queue.

storeSees: no new site. The house adds no new open of a context:
resumeAgent reopens through reopenAgentContext, whose three arms record
their views; the namespace-named worker opens isolated with no view, as
copy-path workers do. agentSees: no new view decision; the mail, result
and restart-notice checks go by the recipient (dendrite.to,
isMailCopyFor). Notice tags: none new; the house's new texts use
NOTICE_TAG (agentLifecycle, tuneOutCancelled, routing); main's [settings]
and [tab <id>] toolset labels stay as main wrote them (built from the
toolset's and the server's names, as toolset labels are). The server
config's `presentation` ('full' | 'tab' | 'closed-tab', HostPresentation)
is the host's tool presentation, not this branch's presentation index
(presentation.ts, the agent's window); the two share no name in code.
Teardown: the house's forgetAnnouncedLocus (agents not declared by
configuration) and the subconscious's stopReader are in
releaseAgentRuntime.

Changelog: 291-chronicle-0.5 now says context-manager was ^0.23.1 (0.29.0's
range), that 0.23.0 and 0.23.1 lack the window APIs, and carries the
consumed context-manager-floor fragment's sentence: 0.24.0 carries 0.23.1's
changes, so 0.29.0's runtime-settings preview entry holds under it. The
other fragments 0.29.0 consumed held nothing of this branch's.

Tests adapted:
- compression-after-release, the house's resumed-job case: the job's first
  run wrote its window down, so the resumed context compresses its window;
  the backlog is presented to the reopened manager (presentRows), as the
  resumed run's adoption would, where main compressed the record.
- audience-stamp-writers: noticeHeartbeatTurn listed as a 'turn' writer.
- audience-stamp: a shared fork's mail from its parent is stored as
  `mira (mail)` (the house's 6b7d2c2); still for the fork, not its own row.

Tests: built with zero errors; 1 stale compiled test removed
(utils-surface, which main deleted); fixtures copied. Targeted run (the
three conflicted tests; presentation*, window-*, read-marks,
presented-watermarks, held-for-another-agent, history-window-marks,
stored-injections, stored-removal, audience-stamp*, dependency-ranges,
dendrite-*, tune-out-*, compression-after-release, context-options,
subconscious-window-anchor, offline-recovery-presented,
rollback-cut-accounting, shed-window, continuing-start-silence,
held-wakes-gated, no-deferral-residue, heartbeat, exhausted-turn-rearm,
supplied-text-in-context, foreign-rounds, and every test 2fbff64 adds or
changes; 88 files): the first 70 gave 1604 tests, 1601 pass, 1 fail
(compression-after-release's resumed job, adapted above), 2 skipped; the
other 18 (a glob the first list missed: audience-stamp*, presentation*,
window-undo-redo, dendrite-fork-view and seven tune-out-*), 188 of 188
after the adaptations. Full suite (npm test), before the two audience
adaptations: 9175 tests, 9126 pass, 2 fail (those two), 47 skipped; after
them: 9175 tests, 9128 pass, 0 fail, 47 skipped (runtime-conditional:
disks that fold case or normalization, and the like).

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