Repository navigation
feat(prose)!: a heartbeat turn holds its plain speech as drafts - #861
Conversation
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>
There was a problem hiding this comment.
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.
TurnRoutingalready carriesnoChanneland a frozen locus, and plain speech with nowhere to go is already held asno-destinationdrafts. The PR adds aheartbeatflag next tonoChannel. It routes through the existingholdProseDraftsand 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
draftsresend path and[delivered]receipt. The resolver answersnullthe same way it already does fornoChannel. 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.mdfragment 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:
- High severity. An autoContinue continuation of a heartbeat turn loses the heartbeat flag and posts to the global fallback.
- Medium severity, test coverage. No test covers a heartbeat turn's autoContinue continuation or a budget-restart/retry stream.
- Medium severity. Every bounce in a heartbeat turn is made wakeless, including failed deliberate
>>/>>>sends. - Medium severity, test coverage. No test covers re-pin in a heartbeat turn, the activeChannelResolver null, or the skipped locus announcement.
- Medium severity. Skipping the locus announcement leaves the agent believing its voice still goes to the last announced channel.
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 tocallingTurn.locus ?? null. A heartbeat turn now has no locus, soregistry.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 insleepnow 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 aboverouting.triggerChannelsays 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.routeLocusSpeechcallsholdHeartbeatProse(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.serverIdfor 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.
|
@antra-tess At 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.
A released sentence. 0.28.0's What a resident meets. We ran heartbeat turns at
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 |
…, 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
|
Review findings outside the inline threads:
Also in aafec67: the 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 |
There was a problem hiding this comment.
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)
- Should fix. Batch heartbeat classification reads the merged trigger's source and silence, so queue order decides whether prose is held or posted.
src/framework.ts:685,src/framework.ts:17128,src/framework.ts:17214and 22 more places By reading, isHeartbeatWake (src/framework.ts:689) tests the merged batch trigger, which takessourcefrom requests[0] (src/framework.ts:17128) but drops suppressProse when the batch isn't all silent (src/framework.ts:17236). So [tick or silent tick, timer] holds the timer's words as drafts, and [timer, tick] posts the heartbeat's words to the global fallback. This contradicts "other channel-less wakes ... route as before". No test batches mixed wakes. To confirm: Queue a heartbeat tick (or a silent tick) and then a channel-lesssource: 'test'wake in one tick after a room message. Check whether the publish is withheld. Then reverse the order and check whether the heartbeat's speech posts to the room. Already raised on this pull request. - Should fix. Framework bounce and explicit-continuation wakes drop heartbeat provenance, so recovery turns publish to the global fallback.
src/framework.ts:20230,src/framework.ts:20302,src/framework.ts:21181and 4 more places - Should fix. Exhausted autoContinue re-arm strips the heartbeat continuation mark, so the re-armed turn publishes to the fallback.
src/framework.ts:21022,src/framework.ts:21025,src/framework.ts:6073and 1 more place - Should fix. Gate-delayed heartbeats are emitted as gate wakes, so they bypass heartbeat draft routing and post to the public channel.
src/framework.ts:689,src/framework.ts:4404,src/gate/event-gate.ts:3779and 1 more place The description's Decision 4 says a gate-batched heartbeat "wakes withsource: 'gate', so its turn isn't recognized and routes as before". That's fine as a scope limit, but the breaking changelog fragment (changelog.d/heartbeat-prose-drafts.breaking.md:1-16) promises without qualification that heartbeat turns no longer post to the latest inbound channel, and lists only timers and host requests as unchanged. Operators with a gate rule batching heartbeats aren't warned. To confirm: Configure a gate debounce rule matching source 'heartbeat', post a room message, fire a tick and let the debounce flush. Check that the turn's plain speech is published to the room. Named in this pull request's description or commit messages, and it still weighs in the recommendation: by reading, nothing there gives a reason for leaving it that holds. - Should fix, text. Heartbeat turns append a durable routing row every tick, contrary to the decision text that says no per-turn notice is added.
src/framework.ts:18480,src/framework.ts:21209,src/framework.ts:21210and 1 more place - Should fix, text. Docs claim heartbeat bounces and holds never wake or start runs, but deliberate-target bounces and autoContinue still do.
changelog.d/heartbeat-prose-drafts.breaking.md:11,src/framework.ts:19444,src/framework.ts:20230and 1 more place - Should fix, test. No test pins that a heartbeat bounce leaves the bounce-wake streak unchanged.
test/prose-drafts.test.ts:4397,test/prose-drafts.test.ts:4408,src/framework.ts:20230
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:20024The heartbeat continuation row (src/auto-continue.ts:46) says that anything written without skip_reply is held as a draft. By reading, a hybrid>>>#roomenvelope 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:18484and 12 more places A heartbeat push from an MCPL serverheartbeatthat 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:635and 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:18472and 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:22496In 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:18242and 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:4304and 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:9503and 4 more places By reading, after a heartbeat turn settles its routing entry keepsheartbeat, 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.
…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>
|
Re-review (d1ff424), items outside the threads, at 563e39b:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
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)
- Should fix. Quiesce parked-wake coalescing key ignores source and heartbeat status, so heartbeat wakes get replaced or mis-marked.
src/framework.ts:16950,src/framework.ts:17244,src/framework.ts:16650and 2 more places - Should fix. continuesHeartbeat is derived from the merged trigger's source, taken only from the batch's first request, not the whole batch.
src/framework.ts:17219,src/framework.ts:17244,src/framework.ts:21032and 5 more places
For a review with Sol in place of Terra as one of its two readers, a maintainer can comment /review deep.
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>
There was a problem hiding this comment.
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:23112and 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:21213and 1 more place By reading: a push from a server namedheartbeatwhosetargetAgentsnames a conversation fork marks the fork's turn as a heartbeat's (src/framework.ts:21194).resolveLocusstill 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:6076and 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:29057The 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:4248The 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:17216and 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:17189and 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:6362and 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.
|
Final review (3a24c47): merge recommended. On its minor items:
🤖 Generated with Claude Code |
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.
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.
…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.
… 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>
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:systemnotices from setting that fallback, but ordinary ambient messages still do. Nobody was waiting in that channel for the heartbeat's words.Change
isHeartbeatWake): nochannelId, nosuppressProse, andsource: 'heartbeat'(the framework's own heartbeat, ticks and reminders, or an MCPL server whose id isheartbeat), or a framework wake carryingInferenceRequest.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 carriesheartbeat: true, kept across its streams.nullfor it, as fornoChannel.no-destinationdraft, mid-turn and at the turn's end, instead of failing atdeliverSpeech.[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.[delivered]receipt names the drafts.>>target that fails still wakes the agent, as any bounce does.[continue]row says its plain words are held.>>/>>>destination, the agent's ownchannel_open, and a re-pin.debounce/judge), which wakes as the gate's batch withsource: 'gate'; the fragment says so.Decisions for antra
[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.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:>>>target holds its unprefixed words;>>>delivers;>>still wakes;On the first revision, the four behavior tests then in the file failed with
main'ssrc/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.d/heartbeat-prose-drafts.breaking.md🤖 Generated with Claude Code