Repository navigation
feat(skip_reply): name the heartbeat's wake due before the self-wake - #858
Conversation
…self-wake
skip_reply's confirmation promised "Self-wake in ~Ns unless something wakes
you first" even when the framework knew its own heartbeat tick or a reminder
would wake the agent sooner, and that turn would cancel the self-wake (any
turn start does). The agent then expected a wake that never came.
When the framework arms a skip_reply self-wake, it now finds the heartbeat's
soonest tick or reminder due before it that the agent's rules let wake the
agent, and the confirmation names it:
Turn ended; nothing sent. Your heartbeat (a check-in prompt, every 10 min)
wakes you first, in ~300s, and that wake cancels your ~900s self-wake: set
it again then if you still want it.
A skip with no wake, a self-wake due first, or a heartbeat the rules hold
back reads as before. The wording lives in skipReplyNote; the framework
passes the first wake to the registry through the agent origin (firstWake).
Heartbeat.upcomingWakes() gives each wake's kind and takes whose ("its" for
the judge, "your" for the agent), replacing a prefix match on "its reminder".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: merge and fast-follow. 3 medium severity findings can be fixed after the merge.
Reviewed commit 33d79f3 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 refines the wording of the existing skip_reply confirmation using the framework's own unreleased heartbeat, and each piece goes in the module that already owns that job.
More
It adds no new dependency, service or concept.
Findings
3 findings of medium or high severity, see the inline comments:
- Medium severity, test coverage. The rules case holds back ticks and reminders together, so it can't catch a probe applied to the wrong kind.
- Medium severity, test coverage. No test for agents the heartbeat doesn't wake, or for a paused or absent heartbeat.
- Medium severity. For a silent tick, the agent-facing claim relies on a probe that differs from how the tick is actually judged.
For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.
… review round on #858 - Each tick and reminder is probed as the event deliverHeartbeat has the gate judge: a silent tick as silent, each with the text it will bring (HeartbeatUpcomingWake.silent/.text). Ticks and reminders share the server id `heartbeat`, so a rule tells them apart only by text, which the placeholder-text probe never showed it. - No definite note while a gate.js may decide (EventGate.scriptMayDecide, GateScript.mayDecide): evaluate() consults it before the rules, and a probe can't. - Tests: ticks held back while reminders pass and the reverse, a rule on the server id both share, a rule on the silent flag, a content rule that holds one reminder and not another; a fork, a paused heartbeat, no heartbeat, and a gate.js beside gate.json. Mutation-checked: the old probe shape, dropping the gate.js check, and dropping the target check each fail them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review follow-up (86cbd8b):
Mutation-checked: the old probe shape, dropping the gate.js check, and dropping the target check each fail the new tests. Targeted suites 61/61, and CI is green on 86cbd8b across the full matrix (Ubuntu Node 22.12, 24 and latest; macOS; Bun). @nissa-seru, would your swarm take a look at this one when it has a moment? |
|
/review deep |
|
A deep review of this pull request has been requested. It usually takes less than 30 minutes, though it may first wait for an earlier review of this pull request to finish. When it finishes, the review is posted on this pull request. The run: https://github.com/anima-research/agent-framework/actions/runs/40591282074 |
|
@antra-tess The built-in heartbeat is in 0.28.0, so this changes released behaviour, and the The heartbeat is released. The description says #827 is unreleased and that the heartbeat is "still in a pending What that means for the changelog.
"Wakes you first" can be wrong. "Set it again then." In the description's example, "wakes you first, in ~300s, and that wake cancels your ~900s self-wake: set it again then if you still want it", "it" reads as the ~900s self-wake. Set again in the heartbeat's turn, 300 s in, that wakes the agent about 300 s later than it first asked. Naming what's left saves the agent the subtraction: "…set it again then, for the ~600s left, if you still want it." Rupert-2634, Crispin-2713 and Ada-1017 read this pull request's first commit, — Daniel-2612 |
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.
Reviewed commit 86cbd8b by reading the code, in four stages: a plan by claude-opus-5-5; two readers on every task of it, gpt-6.1-sol and claude-haiku-5-5; their findings grouped into issues; and a judge, claude-opus-5-5, that tried to refute each issue. Nothing was run, so each issue is a lead from reading, and each blocker or should-fix says the run that would confirm it.
Between them, the reads of the readers that answered returned every changed line of the change (353 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. Heartbeat certainty probe reads rate_limit and passive_sample state at arm time rather than the state delivery will see.
src/framework.ts:5530,src/framework.ts:5546,src/gate/event-gate.ts:3330and 18 more places heartbeatWakeBefore (src/framework.ts:5546) gives a definite "wakes you first … cancels your self-wake" note from EventGate.probe. For rate_limit and passive_sample, probe reads the bucket or counter at arm time (src/gate/event-gate.ts:3322, 3330), not at the wake's due time. If another event takes the token first, the tick is held at fire time and the self-wake fires after the agent was told it was cancelled. Refills or counter advances can also make the note name the wrong wake. To confirm: Use a heartbeat rate_limit rule with tokens:1. Call skip_reply(wake_in_seconds:900) with a tick due at 300s, then call heartbeat_trigger before 300s. Expect the note to name the tick, but at 300s no turn starts and the self-wake fires at 900s. Already raised on this pull request. - Should fix. Heartbeat-first note drops the 'unless something wakes you first' hedge and ignores other known earlier wakes.
src/framework.ts:5501,src/framework.ts:5510,src/framework.ts:5546and 7 more places
Minor (12)
12 minor issues, by file
- Changelog fragment files a defect fix and new API surface under the single changed kind.
changelog.d/skip-reply-heartbeat-aware.changed.md:3,src/framework.ts:5530,CHANGELOG.md:154The fragment changelog.d/skip-reply-heartbeat-aware.changed.md files everything under Changed. Yet its second bullet fixes 0.28.0's released judge list, which probed with placeholder text and missed text-keyed rules (CHANGELOG.md:154). Its third bullet adds exported API. The description sayschangedfits because the heartbeat 'is still in a pendingaddedfragment', but it shipped in 0.28.0 (CHANGELOG.md:147). Named in this pull request's description or commit messages. - Description and changelog wrongly treat the shipped built-in heartbeat as unreleased and pending in an added fragment.
CHANGELOG.md:15,CHANGELOG.md:147,src/framework.ts:25999and 7 more places The description calls the built-in heartbeat "#827, unreleased" and says it "is still in a pendingaddedfragment". At head, CHANGELOG.md:147 lists it under the released 0.28.0 section (CHANGELOG.md:15), and changelog.d/ has no heartbeat fragment. So 0.28.0 users of the heartbeat will get the new skip_reply wording. The .changed.md category still fits, but its stated reason is false. - First-wake selection skips debounce and judge entries that probe as non-triggering, so an earlier turn can go unnamed.
src/framework.ts:5546,src/gate/event-gate.ts:3311,src/gate/event-gate.ts:3519and 7 more places src/framework.ts:5546 skips heartbeat wakes that probe as non-certain and names the first certain one. A tick matched by a debounce or judge rule probes false (src/gate/event-gate.ts:3311), yet evaluate batches it and can still start a turn (event-gate.ts:3160). So a debounced tick at ~60 s can cancel the self-wake before the reminder the note says 'wakes you first'. - Heartbeat confirmation reads cached policies that evaluate has not yet reloaded from gate.json.
src/framework.ts:5530,src/framework.ts:5546,src/gate/event-gate.ts:3351and 2 more places The confirmation probes the gate's cached compiledPolicies (src/gate/event-gate.ts:3351-3352) without reloadIfChanged. Delivery's evaluate reloads gate.json first (event-gate.ts:3037). After a direct edit to gate.json that holds back heartbeat pushes, the note can still promise the tick wakes the agent and cancels the self-wake. The tick is then refused when it fires. - Commit message says rules distinguish ticks from reminders only by text, though silent ticks are identified by metadata.
src/framework.ts:5574,src/gate/event-gate.ts:3013,test/heartbeat.test.ts:668By reading, the second commit's message says ticks and reminders share the server id, "so a rule tells them apart only by text". That's untrue for silent ticks: the gate event carriessilent: truein its metadata (src/framework.ts:5574), and the change's own test holds a silent tick back withmetadataTrue: ['silent']while reminders still wake. A silent tick's content is empty (src/framework.ts:5567), so a text rule can't match it at all. - GateScript.mayDecide reports a removed gate.js as able to decide through its cached active flag.
src/gate/gate-script.ts:168,src/gate/gate-script.ts:68,src/gate/gate-script.ts:70and 7 more places By reading, after a loaded gate.js is deleted, GateScript.mayDecide (src/gate/gate-script.ts:169) keeps returning true through the cachedactiveflag. That flag clears only when evaluate() reloads (gate-script.ts:70-71). Until another event is evaluated, skip_reply gives the plain note, although delivery would stop the worker and let the policies allow the heartbeat. The test's paused case is not affected: the test never loads gate.js. - Probe renders heartbeat text at the scheduled or overdue time while delivery renders it at the fire-time clock.
src/heartbeat/heartbeat.ts:358,src/heartbeat/heartbeat.ts:365,src/heartbeat/heartbeat.ts:245and 13 more places The probe text is built at the scheduled time (src/heartbeat/heartbeat.ts:365, tickText(at)), while delivery builds it at fire time (heartbeat.ts:582, 615). For an overdue or late reminder, delivery adds ", due …" (heartbeat.ts:245) and the probe text lacks it. A content rule on that header or on the timestamp can then probe differently than it decides. The changelog's "with the text it will bring" overstates this. - Description under-counts the added API surface and omits gate.js, tickText and silent/text additions from its rent accounting.
src/heartbeat/heartbeat.ts:117,src/heartbeat/heartbeat.ts:120,src/heartbeat/heartbeat.ts:123and 9 more places The description says the Heartbeat API adds "an optional parameter and an extra field". HeartbeatUpcomingWake adds kind, silent and text (src/heartbeat/heartbeat.ts:117-123), and there is a new public EventGate.scriptMayDecide() (src/gate/event-gate.ts:3340). The description's "reads as before" list leaves out the gate.js case. It also doesn't say the reply judge's list is now probed with each wake's real text. The changelog fragment covers all of this. - Unconditional tick date formatting throws RangeError for accepted intervals beyond the Date range.
src/heartbeat/heartbeat.ts:353,src/heartbeat/heartbeat.ts:308,src/heartbeat/heartbeat.ts:751and 2 more places A host default likeintervalSeconds: 1e14passes readSettings (src/heartbeat/heartbeat.ts:759) without the canHoldTime check the configure tool applies (src/heartbeat/heartbeat.ts:437). start() then puts nextFireAt past the Date range. By reading, upcomingWakes' new tickText call (src/heartbeat/heartbeat.ts:353) would throw RangeError from formatToParts. That would break the judge's list and skip_reply after its timer is armed (src/framework.ts:25999). - Note treats a gate-approved heartbeat request at its scheduled time as a guaranteed turn that cancels the self-wake.
src/mcpl/skip-reply-wake.ts:70,src/framework.ts:5546,src/framework.ts:5617and 20 more places The note (src/mcpl/skip-reply-wake.ts:65-66) says flatly that the heartbeat's wake cancels the self-wake. Only a fresh turn start clears it (src/gate/event-gate.ts:4021), and the heartbeat only queues a request. If a provider cooldown, a quiesce, or a host InferencePolicy that drops heartbeat requests (src/types/framework.ts:403) holds that request past the self-wake's deadline, the self-wake still fires. The effect is an extra wake the agent was told was cancelled. - Remaining-time assertions use a ten-second wall-clock bound that fails under load.
test/heartbeat.test.ts:50,test/heartbeat.test.ts:635,test/heartbeat.test.ts:640By reading, the remaining-time assertions at test/heartbeat.test.ts:635 and :640 measure real Date.now time (the mocked clock is reset at :50, and src/framework.ts:5545 reads Date.now). A stall of over 10 s between heartbeat start or reminder_add and the skip would fail them even when the note names the right wake. This is unlikely in practice. - Tests section says only the new tests fail on main, but a missing export breaks the whole skip-reply-wake test module.
test/skip-reply-wake.test.ts:6The description says "Againstmain's source, the new tests fail and the rest pass". By reading, test/skip-reply-wake.test.ts:6 imports skipReplyNote, which main doesn't export. In this ESM package that import fails when the module links, so the file's existing readSkipReplyWake and skipReplyWakeRefusal tests fail on main too.
In existing code (1)
1 issue in code that was there before this change, which doesn't weigh in the recommendation
- Minor. Gate.js guard covers the skip_reply note but not the reply judge's heartbeat certainty.
src/framework.ts:5513,src/framework.ts:5531,src/framework.ts:5544and 3 more places With a gate.js that may skip heartbeat events, heartbeatWakesFor (src/framework.ts:5531) still marks the tick certain from the policy probe. The reply judge's list (framework.ts:5513, reply-judge.ts:193) then shows it with no 'maybe', while the skip_reply note withholds the same wake (framework.ts:5544). A judge could answer WAIT for a wake the script holds back. The base did the same. Already raised on this pull request.
…p-review round on #858 Nissa's swarm and the deep review both found "wakes you first" can be false: a debounced batch, a queued request, a used-up rate-limit token, a cooldown, a policy hold or a gate.js can wake the agent sooner or hold the tick back, and the probe can't foresee any of them. The note now keeps the usual line and adds the heartbeat's wake as information, true however the gate decides: Turn ended; nothing sent. Self-wake in ~900s unless something wakes you first. Your heartbeat (...) is due sooner, in ~300s; if it wakes you, the self-wake is cancelled, so set it again for the ~600s left if you still want it. - "for the ~Ns left" (Nissa's suggestion) names what remains of the timer. - The gate.js guard and its API (EventGate.scriptMayDecide, GateScript.mayDecide) are gone: the hedged note doesn't need them. - upcomingWakes() keeps 0.28.0's signature; the new list is Heartbeat.scheduledWakes(whose), typed HeartbeatScheduledWake, so a subclass overriding upcomingWakes still type-checks. - A tick past the last time a date shows (a host interval has no ceiling) lists its message without a time, instead of throwing RangeError from the judge's list and skip_reply. heartbeatWakeBefore also catches, since the timer is armed before it runs. - Changelog split: .changed (the note), .fixed (the judge's list probe, a 0.28.0 behavior), .added (scheduledWakes, HeartbeatScheduledWake). - Test time bounds loosened so a slow machine doesn't fail them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@nissa-seru Thanks to Daniel-2612, Rupert-2634, Crispin-2713 and Ada-1017. All four points are taken in d765679:
The probe change in the previous round still stands, and it now has its own |
|
/review |
There was a problem hiding this comment.
Automated review
Recommendation: merge and fast-follow. 1 medium severity finding can be fixed after the merge.
Reviewed commit d765679 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 change refines two features the project already ships, skip_reply's self-wake and the built-in heartbeat, and puts each piece in the module that already owns it. It follows the project's conventions for changelog fragments, tests and the PR body.
5 reasons with sources
- It improves behavior that already exists rather than adding new scope. The skip_reply self-wake note and the heartbeat's upcomingWakes for the reply judge both exist on main. The change adds a line to the note and probes the gate with the real event text instead of a placeholder. (src/mcpl/skip-reply-wake.ts, src/heartbeat/heartbeat.ts (base))
- The code sits where the existing structure puts it. The base code already says the framework owns the EventGate and arms the self-wake before dispatch, while the registry writes the note. The change keeps that split. The framework works out which heartbeat wake comes first and passes it along with the agent's dispatch info as
earlierWake. The wording lives in skip-reply-wake.ts next to readSkipReplyWake, and the schedule details stay in Heartbeat. (src/mcpl/channel-registry.ts handleToolSkipReply comment (base)) - It refactors rather than duplicating anything. The reply judge's list and the new note both go through one helper, heartbeatWakesFor. upcomingWakes() now calls scheduledWakes() instead of running a second version of the same loop. (src/framework.ts)
- The new public API is small and limited to the heartbeat. The only additions are
Heartbeat.scheduledWakesand the exported typeHeartbeatScheduledWake, both declared in an .added fragment. There are no new dependencies, services or concepts. (src/index.ts) - The changelog fragments and PR body follow the contributing guide. There are three fragments, each a single bullet with a valid category name. The body has Problem/Change/Tests sections, the AI footer, and an honest note about the local suite. The review's findings that some parts lack tests are matters for that code review, not for whether the change belongs. (CONTRIBUTING.md, changelog.d/README.md)
Findings
1 finding of medium or high severity, see the inline comment:
- Medium severity, test coverage. The reply judge's list change (the .fixed fragment) has no test of its own.
3 more findings, less severe
- Low severity, documentation,
src/heartbeat/heartbeat.ts:336-338. upcomingWakes() is called unchanged, but it now returns the full wake objects. The.addedfragment and the PR sayupcomingWakes()is unchanged. It now returnsthis.scheduledWakes()directly, so each entry carrieskind,silentand the fulltext, including a reminder's complete untruncated message and header. It also formats that text withformatHeartbeatTimeon every call. The declared type is the same, but callers that spread, serialize or log these entries now get extra data. Either map the result back to{ at, certain, what }or drop the 'unchanged' claim. - Low severity, maintainability,
src/framework.ts:26004-26006. Self-wake log line calls a reminder 'the heartbeat'.earlierWakecan be a pending reminder as well as the next tick, becauseheartbeatWakeBeforepicks the first certain entry fromscheduledWakes, which includes reminders. The log line always says; the heartbeat is due sooner, in ~Ns, so an operator reading[self-wake]logs can't tell a reminder from a tick. LogearlierWake.what(or the kind) instead. - Low severity, test coverage,
src/framework.ts:5543-5552. The fallback when the heartbeat's wakes can't be read has no test.heartbeatWakeBeforecatches errors fromheartbeatWakesFor, which can come from the gate probe or fromreminderText/formatHeartbeatTime. It logs the error and returns undefined, so the confirmation should still go out with the plain note. The PR describes this as the intended degradation, but no test makes the probe orscheduledWakesthrow and checks that the skip_reply still gets its plain note and the self-wake stays armed.
For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.
#858 - The reply judge's list (upcomingWakesFor) gets its own test: a reminder held back by its text and a silent tick held back by its flag are "maybe"s, the others certain, in the judge's "its" wording. - When the heartbeat's wakes can't be read, skip_reply sends the plain note with the self-wake armed, and the failure is logged (tested by a probe that throws). - upcomingWakes() returns 0.28.0's fields only ({ at, certain, what }), as the changelog says it's unchanged. - The [self-wake] log line names the wake due sooner (a tick or a reminder), not always "the heartbeat". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review follow-up (ab9a671), on the review of d765679:
Affected files pass locally: heartbeat 31/31, skip-reply-wake-capability 19/19. |
110 commits from main, 20 pull requests: #852 (supplied text can't read as the framework's own), #853 (every check of whether an agent sees a row asks its view), #827 (the heartbeat moves into the framework), #846 (exhausted turns re-armed), #830 (chat:system notices), #801 (the reply judge), #840, #843, #839, #791, #845, #847, #849, #822, #854, #836, #855, #857, #858, #859 and the 0.28.0 release. package.json takes main's version and ranges (context-manager ^0.23.1, membrane ^0.8.0). Conflicts: - src/framework.ts, TurnRouting: both fields kept, the branch's addressedElsewhere and main's heard (and both in freshTurnRouting). - src/framework.ts, createAgent: main's `sees` predicate, recorded in storeSees, is the branch's composition: heldFromResidents (the RESIDENT_VIEW_FILTER rule) and !isAnotherAgentsInjection; the view filter reads it. - src/framework.ts, tool-boundary drain: the branch's releaseLandedMail and main's noteSpokenTo both run; the injection skip asks agentSees (main) where the branch asked heldFromResidents, which it subsumes. - src/framework.ts, the fork wake and the routed fork wake: the branch's tag-only isAddressedMessage, with main's systemNoticeMark added. - src/framework.ts, a coalesced batch's wake: tag-only addressed, with main's note on why it carries no systemNotice mark. - src/framework.ts, isUnreadStoredMessage: main's form (the slot's readers each asked their own view) over the registry's sharedSlotReaders. - src/framework.ts, noteArrival: main's agentSees and heard, the branch's arrival with `addressed`. - src/framework.ts, noteForOtherResidents: the registry's readers, with main's noteSpokenTo. - src/framework.ts, the channel tool dispatch: the branch's publishInputWithTurnLocus, with main's earlierWake on the origin. - src/framework.ts, tune_out repeated on a tuned-out channel: the branch's awaited form, the name quoted by channelRegistry.quotedShown (#852). - src/mcpl/channel-registry.ts, markAccepted: the branch's (admission's noteDelivered sets the locus); main's chat:system is a marker (isMarkerTagged), so noteDelivered's rule covers it, and its comment says so. - src/mcpl/channel-registry.ts, tuneOutEndText: the branch's serverId form for a channel no name picks alone, a name written by quotedValue. - src/tune-out/coordinator.ts: the branch's readOptions, all-except refusal, renderHeldLine/historyTool, reader-fork invocation and cancel_tuneout scope logic kept; main's quotedShown, NOTICE_TAG texts and suppliedValue author adopted in each. - test/batched-wake-locus: main's new two-closed-channels test is placed in the branch's rig (framework read from rig.framework()); the branch's ambient-only tests kept. - test/channel-registry-routing: the branch's noteDelivered comment, with main's chat:system added to the marker loop. Main's changes integrated into the branch's structures: - heartbeatTargets reads registry.untargetedRecipients() (an untargeted push's recipients) and heartbeatRefusal registry.homeChannel, in place of conversationAgentHomes. - releaseAgentRuntime also drops #846's re-arm count and exhausting re-arm and #840's exhausted-turn record, and ends the run of stated waits (endStatedWaitRun). - storeSees is recorded for a derived manager (its source's predicate, which context-manager's derive keeps as its view filter) and for a context reopened on the shared slot (the residents' view). - The closed-channel invitation for a deliverTo correction reads the recipient admission chose, where main rewrites event.targetAgents. - #852 on the branch's own texts: authorLine/presentAuthor write the author through suppliedValue; the addressed-elsewhere notice's author too; `Focus` and `Agent lifecycle` are NOTICE_TAG entries and their notices use them; tune-out's branch-only notices use NOTICE_TAG and name channels by shown/quoted names. Tests read onto this branch, each named in a comment: - heartbeat (two cases): the fake fork is a registry record (conversationForkSpec), not a conversationAgentHomes entry. - prose-drafts (stated provider wait, two cases): scout is made a conversation agent through its registry record (homeChannel, no provider scheduling), as main does through conversationAgentHomes. - channel-registry-routing (chat:system locus): each message is delivered with noteDelivered, where admission records the locus here. - supplied-text-in-context (three cases): a channel message is stored and sent under its author's name (presentAuthor); the delivered body's digest is compared with the marked body before that decoration. - tune-out-reader-queued: its fake registry gains quotedShown. Tests: built with zero errors; 29 stale compiled tests removed. The targeted run (dendrite-*, tune-out-*, batched-wake-locus, channel-registry-routing, compression-after-release, stored-injections and main's added or changed framework-area tests, 63 files) gave 2507 tests, 2491 pass, 14 fail, 2 skipped before the readings above; after them, 73 files (those 63 and all-except-unit, all-except-e2e, author-presentation, module-rows-held-from-residents, refusals-are-error-results, state-read-and-write-errors, inbound-source, reinit-after-branch-move, channel-agent-words, channel-lifecycle-order) gave 2860 tests, 2858 pass, 0 fail, 2 skipped (main's runtime-conditional skips). The full suite was not run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main moved thirteen merges past 8ff287d. anima-research#858 (skip_reply names the heartbeat's earlier wake) conflicted first, and the scratch merge of all thirteen gave ten blocks, nine in src/framework.ts and one in test/present-while-acting.test.ts. Each keeps both sides: - The imports keep anima-research#852's supplied-text helpers and notice tags beside readInboundSource. - The request marks keep anima-research#846's REARMED beside the wake channels, the turns a request continues and the retake mark, with anima-research#846's helpers after them. - The delivered receipt and the no-channel routing notice keep this branch's text inside anima-research#852's NOTICE_TAG. main's side of the routing notice was its July wording, which this branch's replaced. - skip_reply's self-wake keeps anima-research#858's armedAt and earlierWake, and still passes the turn that armed it to armSelfWake. - A status's locus keeps a private turn's null, with anima-research#852's suppliedValue for a channel's id. - The MCPL tool-list notice keeps its notice variable, which FRAMEWORK_NOTICES marks, with anima-research#852's suppliedValue for each tool's name. - dispatchChannelToolCall takes anima-research#858's earlierWake, under this branch's one-sentence comment. - channel_open's result puts its routing word first, as anima-research#852 does so a result cut for its length keeps it, with this branch's private-turn wording. - The test file keeps main's "channel_open's word ... survives a result cut" beside this branch's private channel_open tests.
Problem
skip_replywithwake_in_secondsconfirmsTurn ended; nothing sent. Self-wake in ~900s unless something wakes you first.The built-in heartbeat shipped in 0.28.0 (#827). When its next tick, or a pending reminder, is due before the self-wake and wakes the agent, that turn cancels the self-wake: any turn start does (EventGate.onInferenceStarted). The note gave the agent no sign that its timer would go with that wake, or that it would need to set it again.Change
Turn ended; nothing sent. Self-wake in ~900s unless something wakes you first. Your heartbeat (a check-in prompt, every 10 min) is due sooner, in ~300s; if it wakes you, the self-wake is cancelled, so set it again for the ~600s left if you still want it.A reminder reads
Your reminder "stretch" is due sooner, in ~120s; …. Only a wake the agent's rules as they stand would let wake it is added.gate.js, a cooldown or a host's inference policy can hold the tick back. The note stays true in each of those cases. An earlier round of this PR said "wakes you first", which the reviews below showed can be false.probe. Ticks and reminders share the server idheartbeat, so a rule could only tell them apart by text (or by thesilentflag), which that probe never showed it. Filed as.fixed.skipReplyNote(src/mcpl/skip-reply-wake.ts). The framework owns the gate and the heartbeat. It passes the wake to the registry as the agent origin'searlierWake, as trusted dispatch context, never read from tool input. If reading the heartbeat's wakes fails, the note goes out without that line (the timer is already armed by then).Heartbeat.scheduledWakes(whose?), returningHeartbeatScheduledWake[](exported): anUpcomingWakepluskind,silentandtext.whoseis'its'(the default, the judge's wording) or'your'(the agent's).upcomingWakes()keeps 0.28.0's signature. Filed as.added.intervalSecondshas no ceiling.Changelog
Three fragments, following review:
.changedfor the note's wording on a released confirmation,.fixedfor the judge list's probe (0.28.0 behavior), and.addedforscheduledWakesandHeartbeatScheduledWake.Tests
test/skip-reply-wake.test.ts:skipReplyNotewith no wake (even beside a heartbeat), a self-wake alone, a heartbeat or reminder due sooner with the time left, and the floor of 1 s left.test/skip-reply-wake-capability.test.ts: the registry addsorigin.earlierWake, but only beside a wake it set.test/heartbeat.test.ts:scheduledWakesgives each wake'skind,silentandtext, and words it for'your'.upcomingWakes()lists the same wakes.source: "heartbeat", give the plain note;metadataTrue: ["silent"]rule holds back a silent tick, and a content rule holds back one reminder but not another;main's source,skip-reply-wake.test.tsfails as a module (it imports the newskipReplyNote), and the new cases in the other two files fail.🤖 Generated with Claude Code