Repository navigation
fix(gate): refuse a conversation fork's and an ephemeral run's sleep and wake - #679
Conversation
…and wake The gate's sleep is one for the whole agent: while it lasts, the gate holds back every wake but the heartbeat's and a privileged user's, and it decides a channel message's wake before the message is routed to a fork. So a fork's sleep held back the primary's ordinary wakes until the sleep or the fork ended, a fork's wake ended the primary's own sleep, and an ephemeral run's sleep did the same for the rest of its run. dispatchSleepToolCall now refuses both tools for a conversation fork and an ephemeral run, before the tool runs, as a fork's channel_open is refused. The result says that nothing was set or changed, and why. A fork that can call skip_reply is told its wake_in_seconds is the way to come back to the conversation later. The primary's sleep is unchanged, and still quiets the messages its forks would wake on. With no fork or run able to set a sleep, EventGate.agentGone() keeps only its self-wake cancel. Part of anima-research#646. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s results From the internal review of the previous commit: - A fork's refused sleep is pointed at skip_reply's wake_in_seconds only outside its closing turn, whose end cancels the self-wake. - The refusal tests read the result each refused call queued (its refused flag and error) and assert that no tool started; an ephemeral run's wake is refused too, with the primary's sleep left standing; the primary's own wake and sleep pass through; and a fork's end at stop() leaves the primary's sleep. - The changelog fragment for a gone agent names only its self-wake, and the subagent test drops two checks a refused sleep can't reach. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: merge. The review found nothing of medium or high severity.
Reviewed commit ddfd93a 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 fixes a bug in features the project already has: a fork's or an ephemeral run's sleep and wake were setting or ending the whole agent's single gate sleep.
5 reasons with sources
The refusal sits in the existing sleep-tool dispatch and the changelog fragment follows the project's convention, with no new dependency, API or tool-list change.
- It fixes existing behaviour rather than adding scope: forks and runs keep their sleep and wake tools, but calling them now returns a refusal. The gate has one sleep. The diff stops conversation forks and ephemeral runs from setting it or ending it, and the primary's sleep and wake work as before. The README says nothing about sleep, so this is judged against how the existing code is built. (src/framework.ts (dispatchSleepToolCall); src/gate/event-gate.ts)
- The refusal lives in the existing gate-tool dispatch and copies how that code already refuses tools. It adds a check before tool:started inside dispatchSleepToolCall and returns a refusedResult, as the description says a fork's channel_open refusal already does. It points forks to skip_reply's existing wake_in_seconds instead of adding a second way to self-wake. The search tool couldn't read framework.ts to check the channel_open precedent directly. (src/framework.ts)
- It removes code that no longer does anything instead of adding new things to maintain. agentGone() no longer clears a sleep a gone agent set, because forks and runs can't set one now. Embedders see no change to the API or to tool lists. (src/gate/event-gate.ts (agentGone))
- It follows the contribution rules: a changelog fragment, tests with counts, and a 'Not verified' section. It adds the fragment fork-sleep-refused.changed.md and edits a fragment from #634 that hasn't been released yet. It gives numeric test baselines, tests that fail without the change, and states what it didn't verify. (CONTRIBUTING.md (What a PR should contain; Changelog); changelog.d/README.md)
- It is one coherent change, and the reviewer's low-severity notes about test-helper placement don't affect whether it fits. Every file touched relates to who may set or end the gate's sleep. The notes that gateToolCalls lives in gone-names.ts and that the new tests repeat setup code are code-quality points for the author, not reasons the change doesn't belong. (CONTRIBUTING.md (How changes land))
Findings
No findings of medium or high severity.
2 more findings, less severe
- Low severity, maintainability,
test/helpers/gone-names.ts:12-27. gateToolCalls helper added to a module named for agentGone tracking.gateToolCalls()collects refused, succeeded and started sleep/wake tool results from framework traces, which has nothing to do withgoneNames(). Putting it intest/helpers/gone-names.tsmakes it hard to find, and the imports in three test files are misleading (import { goneNames, gateToolCalls } from './helpers/gone-names.js'). It should go in its own helper module, such asgate-tool-calls.ts. - Low severity, maintainability,
test/event-gate-sleep.test.ts:259-357. Channel setup and forced idle expiry are copy-pasted across the new fork tests. Each of the three new fixture-based tests repeats the same gate/conversations config,channels/register, aneventually(... getDesiredState('editor', CH) === 'open')block with the same inline cast, and achannels/incomingpayload. The closing-fork test also reimplements conversation-routing'sforceExpiryby poking the privatelastActivityandlastConversationSweepfields, as its own comment says. If the router or the registry internals change, every copy has to be updated.
…e fork tests one fixture gateToolCalls() reads the gate tools' queued results, which has nothing to do with goneNames(), so it moves out of test/helpers/gone-names.ts into test/helpers/gate-tool-calls.ts. conversation-routing's forceExpiry() moves to test/helpers/force-expiry.ts, and the closing-fork test uses it instead of repeating its two private-field writes. event-gate-sleep's three fork tests share forkFixture(), which makes the framework, registers and opens the channel, and sends it messages, instead of each repeating that setup. Tests only: each test checks what it checked before, and the one assertion that moved into forceExpiry() keeps its check with that helper's message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The automated review's two lower-severity findings are addressed in
At this head, the five test files, red-first, every removal and the whole suite ran again, as the description's Tests section says. — Hilda-2580 |
main has released 0.24.0, which folded anima-research#634's pending fragment, changelog.d/sleep-timer-ends-at-stop.fixed.md, into CHANGELOG.md and deleted it. This branch had edited that fragment's third entry to name only the self-wake. With the fragment released, the edit goes, and the released entry stands as 0.24.0's behaviour, which this branch's own fragment then changes. Everything else merged without conflict. Against main, each file's changed lines are the branch's own, as they were against 4e3fea3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@antra-tess @Anarchid We're asking one of you to review this pull request before we merge it. It answers the second part of #646. A conversation fork's or an ephemeral run's — Hilda-2580 |
One conflict, in test/conversation-routing.test.ts: main kept the local
forceExpiry and changed it for #6c4048ed (a binding is keyed by server and
channel, getBinding takes a ChannelRef), while this branch had moved it to
test/helpers/force-expiry.ts. The helper stays shared and now takes the
ChannelRef getBinding takes; the routing tests pass { serverId: 'srv', ... }
as main's local copy did, and the fork-sleep test passes its fixture's
'editor' server.
…in's bare sleep harnesses have ephemeralRuns After main's merge (cba968b), three tests failed at the whole suite: - event-gate-sleep's "the primary's sleep still silences a message to its conversation fork" looked the fork's binding up by a bare channel id, through a cast tsc couldn't check, where #6c4048ed keys bindings by server and channel. It now passes the fixture's { serverId: 'editor', channelId }. - present-while-acting's two sleep-announcement tests and send-into-closed-channel's announcement test call dispatchSleepToolCall on an Object.create(AgentFramework.prototype) harness that set conversationAgentHomes but not ephemeralRuns, which the refusal reads on every call. Both harnesses now set it, as inference-failure-bounds' do.
…agent's announcement send-into-closed-channel's announcement test (from main, written while this change was open) made sol a conversation agent and expected its sleep to be announced with the channel's words. This change refuses a conversation agent's sleep before any announcement, which event-gate-sleep's tests pin, so that segment's premise is gone: it is dropped, with a comment saying why. Nothing in the announcement code reads conversationAgentHomes, so no code went with it.
|
@antra-tess @Anarchid This merged at 17:13 UTC as — Hilda-2580 |
A conversation fork's or an ephemeral run's
sleepandwakeare now refused before the tool runs, with a result saying that nothing was set or changed, and why. The gate's sleep quiets the whole agent, so a fork's sleep held back the primary's ordinary wakes, and a fork'swakeended the primary's own sleep. The primary's sleep is unchanged.Part of #646: this settles its design question by keeping the gate's sleep the agent's, which a fork or a run can neither set nor end.
Problem
The event gate has one sleep. While it lasts,
evaluate()holds back every wake but the heartbeat's and a privileged user's, and it decides a channel message's wake before the message is routed to a fork. Any agent that could callsleepset that one sleep:wakeended the primary's own sleep;The
sleeptool's own text speaks only to its caller, whose pings and wakes are suppressed and who won't be woken until the window passes.Changes
dispatchSleepToolCall()refusessleepandwakefor a conversation fork and for an ephemeral run, before the tool runs, as a fork'schannel_openis refused: arefusedresult, atool:failedtrace, and notool:started.sleep: "Nothing was set: a sleep quiets the whole agent, not one conversation." (or "not one run.") A fork that can callskip_reply, outside its closing turn, is also told "To come back to this conversation later, call skip_reply with wake_in_seconds."wake: "Nothing was changed: a sleep is the agent's, so this conversation can't end one." (or "this run").EventGate.agentGone()keeps only its cancel of the gone agent's pending self-wake. Neither a fork nor a run can set a sleep now, so it no longer ends one.What each part is for
skip_reply'swake_in_secondsalready does for one agent.skip_replyneeds, the fork may callskip_reply, and the fork isn't in its closing turn, whose end cancels its self-wake.agentGone()removes a branch no path reaches any more.Tests
These ran on Linux with Node 24.22.0.
main31ff796b:test/event-gate-sleep.test.ts,test/event-gate.test.ts,test/event-gate-selfwake.test.ts,test/conversation-routing.test.tsandtest/ephemeral-no-global-fallback.test.tspass, 124 of 124, three times. A clean build andnpm testgive 6,157 tests, 6,113 passing, 44 skipped, none failing.main's CI at31ff796b(run 39768098482, Ubuntu, Node 24) ran 6,151 and skipped 45. The 6 more here are this change's net new tests, three each inevent-gate-sleep.test.tsandconversation-routing.test.ts. The one fewer skipped is "under Bun, a failed dial's error leaves no credential in its message, cause or stack", which runs where Bun is installed.sleepis refused, so the primary's ordinary wakes aren't held back.wake: refused, and the primary's sleep stays.sleepandwakeare both refused, and the primary's sleep stands. The subagent test makes the same check through a real channel registry.skip_reply, and not given to one that can't. It's not given to a closing fork either.wakeandsleepstill work, and its sleep is kept through a fork's end.main'sevent-gate.tsandframework.ts, 7 of the new and changed tests fail, each a refusal or the remedy line's. The ones that pass there are controls: the primary's ownwakeandsleep, the silenced message to a fork, the forks' ends, andagentGone()'s.sleeponly;wakerefused;agentGone()clearing any sleep;Who notices
sleeporwakecall returns the refusal, and the turn goes on. Nothing else about the agent changes.sleepandwakework as before, and its sleep still quiets the messages its forks would wake on.Not verified
setSleep()andclearSleep(). They are the two tools' dispatch, which acode_executionscript's calls reach too. The tests drive the tools, not a script.Out of scope
wakeends the other's sleep #670: in a framework configured with two agents, one agent's sleep still holds back the other's wakes, and itswakeends the other's sleep. A quiet of a fork's own would need the same change: the gate's suppression moved fromevaluate(), which has no target agent, to where a request names its agent. This change doesn't make it.changelog.d/fork-sleep-refused.changed.md.Written by Hilda-2580 (Claude Opus 5.5). Ada-1017 (Claude Opus 5.5) decided the design, and Kestrel-2409 (Claude Opus 5.5) read and approved each commit.
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.