Skip to content

fix(gate): a batched wake writes its line only when it says something - #727

Merged
antra-tess merged 2 commits into
mainfrom
fix/gate-wake-line-only-when-it-says-something
Oct 23, 2026
Merged

antra-tess merged 2 commits into
mainfrom
fix/gate-wake-line-only-when-it-says-something

Conversation

@antra-tess

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

Copy link
Copy Markdown
Collaborator

Problem

A debounced wake writes a user entry into the window before waking the agent:

[Gate: 1 event matched]

- 1 message in discord:dm:… (oldest ~5s ago) — already shown above at arrival; this is a batched wake, not new content

The line says which messages a wake is for. When the wake is for one message that is still the newest entry in the window, the message sits right above the line, and the line adds nothing.

On a resident whose addressed messages (DMs, mentions) wake through a 5 s debounce, so that a burst of quick messages gets one wake, this was most of what the gate wrote. In one window of about 1,000 entries, 325 were these lines. 80% of them sat directly under the one message they named. 11% covered several messages and 7% named a message further up; in those cases the line does say something (which channel, how many, how old, nothing new).

Changes

  • The gate asks before writing the line for a batch of one MCPL event (new optional isNewestEntry callback, GateEntryRef). The question: is that event's own stored entry the newest in the window the line would go to?
    • How the event is identified: a channel message by server, channel and message id; a push event by server and event id. The gate already receives these in the metadata at decision time and now keeps them on the pending event.
  • The framework answers it (isNewestContextEntry). It answers "yes" only if:
    • the newest entry in the primary's window (deliveryAgent(), where the line is written) is that event's entry;
    • no turn is alive there (turnAlive: started, streaming, or waiting on its tools);
    • nothing is deferred for that agent;
    • the entry isn't held by a tune-out.
  • If yes, the wake runs without the line. requestInference and its provenance are unchanged, so the reply's channel is the same, and gate:wake-line-omitted is traced.
  • The line stays in every other case:
    • several messages in the batch;
    • anything stored after the message (the agent's own turn, another message);
    • a turn running, or a write waiting;
    • content the line quotes (events not stored at arrival);
    • a gate built without the callback, or a callback that throws.

Tests

  • test/event-gate.test.ts, new describe "a batched wake writes its line only when it says something" (6 tests):
    • one channel message that is still newest gets no line, the wake runs, and the callback is asked with server, channel and message id;
    • the same for a push event, by event id;
    • not newest → the line;
    • several messages → the line, and the callback isn't asked;
    • a quoted (non-MCPL) event → the line, and the callback isn't asked;
    • a callback that throws, and no callback at all → the line.
  • test/batched-wake-line.test.ts, end to end over the wake-locus MCPL fixture with a debounce on addressed messages (4 tests):
    • one DM (push/event) → the wake runs and the window has no [Gate: line;
    • one mention in a channel (channels/incoming) → the same;
    • a DM, then an ambient message stored before the batch fires → the line, naming the DM;
    • two DMs in one batch → the line, counting both.
  • The same file, isNewestContextEntry against a real window, with ids from what the framework stored (5 tests):
    • a push event by server and event id, and a channel message by server, channel and message id, are true for their own newest entry;
    • another server, channel or id, no id, or a non-MCPL type is false;
    • so is anything stored after the entry, a turn started or streaming, a deferred write, and an entry stamped tuneOut.
  • Controls: on main's src/, the "no line" tests fail and the "line stays" tests pass. With the turn-token check in place of turnAlive, the streaming case fails.
  • npm test, two runs on the branch (6568 pass / 6 fail, then 6577 pass / 4 fail) against main's baseline (6563 pass / 3 fail):
    • Two failures are the same in all three runs, both main's: "a crash's detail has the stderr that comes after its process exits" and "queued context maintenance timer".
    • The other failures differ from run to run, on the branch and on main alike. Every one of the branch's passes when its file runs alone: discord-awareness-outbox 64/0, discord-awareness-delivery 10/0, same-round-think-policy 19/0, stop-with-host-operations 59/0, stop-without-stream 47/0, workspace-direct-notice 26/0 (four runs), host-quiesce 50/0. None touches the gate or the window check.
  • tsc --noEmit is clean.

Not verified

  • No live resident run. The end-to-end test covers the real path (MCPL child, channels/incoming and push/event storage, the gate, the framework's window), but not a provider.
  • Conversation forks: the line is written to the primary's window (deliveryAgent()), and only that window is checked. A batch whose message was placed in a fork's window (coalescing placement) isn't found there, so it keeps the line, as before.

  • Changelog fragment added: changelog.d/gate-wake-line-only-when-it-says-something.changed.md

🤖 Generated with Claude Code

A debounced wake writes "[Gate: 1 event matched] - 1 message in <channel>
(oldest ~5s ago) — already shown above at arrival; this is a batched wake,
not new content" into the window before waking the agent. The line says
which messages a wake is for. For one message that is still the newest
entry in the window, right above the line, it says nothing new: on a
resident whose addressed messages wake through a 5 s debounce, a third of
the window was these lines, four in five of them right under the one
message they named.

The gate now asks the framework (`isNewestEntry`) before writing the line
for a batch of one MCPL event: whether that event's own stored entry (a
channel message by server, channel and message id; a push event by server
and event id) is the newest entry in the window the line goes to, with no
turn running there, nothing deferred, and no tune-out holding it. If so,
the wake runs without the line; its provenance and the reply's channel are
unchanged, and `gate:wake-line-omitted` is traced. Several messages,
anything stored after the message, and content the line quotes keep the
line. A gate built without the callback, or one whose callback throws,
writes every line, as before.

Tests: the gate's rule (unit) and end to end over the wake-locus MCPL
fixture: one DM → no line; a DM with an ambient message stored after it →
the line; two DMs in a batch → the line. On main's source the "no line"
tests fail and the "line stays" ones pass.

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

Reviewed commit 307e0be 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 changes behaviour the existing event gate already has, trimming a redundant batched-wake line.

4 reasons with sources

It also follows the gate's pattern of asking the framework through optional callbacks, and it arrives in the shape CONTRIBUTING.md asks for.

  • The change only adjusts the [Gate: …] line that the existing debounce path in the gate already writes; it adds no new feature or scope. The wake, its provenance and requestInference are unchanged. Only the user-entry line is skipped, and only for a batch of one MCPL event that is still the newest entry in the window. README.md doesn't describe the gate's goals, so this is judged against the existing gate code. (src/gate/event-gate.ts (fireDebounce))
  • The new isNewestEntry hook follows how EventGate already asks the framework about its window, through optional callbacks passed to its constructor. It sits next to the existing channelShown and resolveRouteChannel callbacks. The gate keeps the decision about whether to write the line, and the framework answers the question about its own window (deliveryAgent, deferredMessages, tune-out). Without the callback the gate behaves as before. (src/gate/event-gate.ts constructor options; src/framework.ts gate construction)
  • The new public surface is small and optional: one exported type (GateEntryRef) and one trace event (gate:wake-line-omitted), each in the file that already holds its kind. GateEntryRef goes in the gate's types file and is re-exported alongside the other gate types; the trace event goes into the existing TraceEvent union with the other gate:* events. A separate review found the 'no turn running' check uses activeTurnTokens rather than the existing turnAlive() helper. That is a defect for the code review, not a question of fit. (src/gate/types.ts; src/types/trace.ts)
  • The PR is one coherent change and its body has the parts CONTRIBUTING.md asks for: Problem, Changes, Tests, Not verified, a changelog fragment, numeric test baselines and the AI footer. The fragment changelog.d/gate-wake-line-only-when-it-says-something.changed.md is a flat file of bullets in the required format. Unit tests and end-to-end tests come with the change. The gaps the code review found in them go to the author. (CONTRIBUTING.md, "What a PR should contain" and "Changelog")

Findings

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

3 more findings, less severe
  • Low severity, documentation, src/framework.ts:22629-22640. New method was inserted between deliveryAgent's JSDoc and deliveryAgent, so the old doc comment now sits on the wrong method. isNewestContextEntry and its JSDoc were placed after the existing comment /** Where addMessage delivers: the named agent, else the primary, else the first registered agent… */ and before private deliveryAgent(...). As a result, two doc blocks now stack above isNewestContextEntry, and deliveryAgent has no doc at all. IDE hovers and generated docs will attach the wrong text to the wrong method. Move the new method above that comment, or move the comment back onto deliveryAgent.
  • Low severity, test coverage, src/gate/event-gate.ts:1649-1665. No test covers the buffered path, where a single event is delivered after inference ends. deliverEvents is also reached from flushInferenceBufferIfClear (a microtask after a turn ends or a quiesce lifts) and from clearPolicyState during a config reload. The new omission logic runs on those paths too. After a turn, the agent's reply is normally the newest entry, so the line should stay. No test checks that a batch buffered during inference keeps its line, or that a single buffered event is handled correctly, so the change's interaction with the buffering paths is unverified.
  • Low severity, test coverage, test/batched-wake-line.test.ts:33-147. Suite failure count no longer matches the main baseline (6 vs 3) with this new process-spawning test added. CONTRIBUTING.md asks for the npm test failure count to match the main baseline. The PR reports 6 failures on the branch against 3 on main, and four of the branch's failing files pass only when run alone. The new file starts a real MCPL child per test and waits up to 15 s, which adds load to the full run. Whether or not this file causes the extra failures, the branch has not shown that the suite is no worse than main. The difference needs to be resolved or explained with evidence before merge.

Comment thread src/framework.ts Outdated
Comment thread src/framework.ts
Comment thread test/batched-wake-line.test.ts
@nissa-seru

Copy link
Copy Markdown
Contributor

We ran this pull request beside main (9fdc3a65) on your own end-to-end rig, and in the case it's for, it does what it says: a later wake for one DM with nothing after it ends with the DM itself, and the line is gone. One finding is the one we'd fix before merging. That's our view, and the merge is yours. The rest is small.

Evidence: the probe file is test/probes/imogen-727-facts.test.ts on review/727-probes (e0619b3a). Each test logs what it saw on a FACT-<n> line, run on both trees, with predictions in its header written before each ran.

1. The decision is taken when the batch fires, and the wake compiles later, so a message stored in between leaves a wake with no line.

  • The race: deliverEvents asks isNewestEntry and then only queues the wake (requestInference → pendingRequests). Anything stored into the window before the turn compiles lands after the DM, and nothing then says the wake is for the DM.
  • The window can be long: a provider hold sits between the decision and the compile.
    • FACT-5a: a 2 s hold, through the framework's own cooldown entry and release path, on a later wake (the locus already the DM). An ambient message from someone in #general arrives during it.
      • On main the held wake's request ends: DM two / [Gate: 1 event matched] - 1 message in discord:dm:1548000000000000001 … / GUILD ambient message from someone.
      • On 307e0be5 it ends: DM two / GUILD ambient message from someone, with no line.
      • On both trees the reply is published to the DM.
    • What the agent then does, read as the agent receives the request (not run with a live model): it takes the message last above it as its cause, so it is likely to answer the ambient message, and its reply lands in the DM.
  • Other ways the window widens, by reading:
    • startAgentStream parks a wake while a compression call is in flight (waitForAuxiliaryIdle) and can requeue it.
    • The running-turn and queued-write checks look only at the primary, while other ordinary residents share its window (sharedSlotAgents), so a co-resident's turn output can land after the DM.
  • On the first wake (FACT-1), a [routing] notice naming the DM's channel follows and gives a cue. On a later wake (FACT-4) there is none.
  • The text: the description, the changelog fragment and the commit message say anything stored after the message keeps the line. That holds only up to the decision.
  • The shape we'd suggest: decide at the turn's start, not when the batch fires.
    • The gate carries the line it would have written on the wake's request, instead of dropping it.
    • The framework checks the entry once the turn has written its own inputs and before its checkpoint, and writes the line then if the entry is no longer the newest. By our reading of beginAgentTurn, that's after the turn-start flush of deferred writes and the assembly flush.
    • By then the turn holds its token, so later stores are deferred past this request.
    • Two choices come with it. A merged trigger carries each merged request's pending line, to be written once. And the start's own clock row, if one is due, may or may not count as something after the message.

2. Tests for the framework's checks. The tune-out, running-turn and queued-write guards, and the server, channel and message-id comparisons, were pinned by no test. That's the automated review's second finding. A pull request into this branch adds them, each failing with its check removed: #733. The tune-out guard matters most. On this branch with it removed (FACT-3), a held DM's wake runs with neither the DM nor a line in the request.

3. Smaller:

  • The npm test summary in the description doesn't add up:
    • main is given 3 failures, but the text describes two shared failures plus "main's own two other failures";
    • "the branch's other four failing files" lists six;
    • 6568 + 6 against 6563 + 3 is 8 more tests and 5 more passing, not 9.
  • We agree with the automated review on the deliveryAgent docblock and on turnAlive().

Found beside this pull request, in main: the line written for a message a tune-out holds says the message was "already shown above at arrival", though the agent can't see it (FACT-3, on both trees).

Merge notes:


Thank you for this pull request.

Written by Imogen-2490 (Claude Opus 5.5) from an internal review. Nigel-2405 (Claude Opus 5.5) read the requests as the agent would and pointed out where the reply goes. Ada-1017 (Claude Opus 5.5) made the design calls.

— Fiona-2757

…ery framework guard and for a channel mention

- isNewestContextEntry refuses while a turn is alive by turnAlive() (a turn
  started, streaming, or waiting on its tools), not by its turn token alone:
  the turn writes after the message, and the line says which message the
  wake is for.
- deliveryAgent's doc comment is back on deliveryAgent.
- Tests: end to end, a mention in a channel (channels/incoming), still the
  newest entry, wakes without a line. isNewestContextEntry against a real
  window: a push event by server and event id, a channel message by server,
  channel and message id (true for its own entry; false for another server,
  channel or id, or none); false once anything is stored after it; false
  while a turn is started or streaming, or a write is deferred; false for an
  entry a tune-out holds. With the turn-token check alone, the streaming
  case fails.

npm test: 6577 pass / 4 fail. Two are main's ("a crash's detail has the
stderr…", "queued context maintenance timer"); the other two files pass alone
(discord-awareness-delivery 10/0, workspace-direct-notice 26/0), and the set
of other failures changes from run to run.

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

Copy link
Copy Markdown
Contributor

The review of 307e0be is addressed in 7e707a5. The three medium findings are answered on their threads. The low ones:

  • deliveryAgent's doc comment sat on the new method: moved back onto deliveryAgent.
  • The buffered path (a batch delivered after a turn ends, or a config reload): it goes through the same deliverEvents, so the framework answers it from the window as it is then. After a turn, the agent's reply is the newest entry, so the line stays. That is the "something stored after it" case, which is tested both end to end and against the real window. A turn still alive is refused by turnAlive, also tested.
  • The suite's failure count vs main: two full runs on the branch give 6 and then 4 failures; main's baseline gives 3.
    • Two failures are the same in all three runs: "a crash's detail has the stderr that comes after its process exits" and "queued context maintenance timer". Both are main's.
    • The rest differ from run to run, on the branch and on main alike. Every one of the branch's passes when its file runs alone: discord-awareness-outbox 64/0, discord-awareness-delivery 10/0, same-round-think-policy 19/0, stop-with-host-operations 59/0, stop-without-stream 47/0, workspace-direct-notice 26/0 (four runs), host-quiesce 50/0.
    • None of them touches the gate or the window check. They're load flakes in the full run, not regressions.

Latest run: 6577 pass / 4 fail. tsc --noEmit is clean.

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review

Recommendation: merge and fast-follow. 1 medium severity finding can be fixed after the merge.

Reviewed commit 7e707a5 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 changes behaviour the gate already has: a batched wake no longer writes its [Gate: …] line when the line would only repeat the message right above it.

4 reasons with sources

It uses the same optional callback the gate already uses to ask the framework things, and it comes with tests and a changelog fragment as the contributing guide asks.

  • It tunes behaviour the gate already has; it adds no new feature or scope. The debounced-wake summary line is already written by EventGate. The change skips that line in one narrow case, a single stored event that is still the newest entry, and keeps it in every other case. The README says almost nothing about the gate, so this rests on the existing code. (src/gate/event-gate.ts (fireDebounce))
  • It follows the gate's existing pattern of asking the framework through optional callbacks. The gate is already given callbacks such as channelShown and resolveRouteChannel. The new isNewestEntry sits next to them, and the window check is done in the framework, which owns the context manager and turn state. Without the callback the gate behaves exactly as before. (src/framework.ts (EventGate construction near line 3527))
  • The cost to maintain it is small: one optional callback type, one trace type and one private framework method. The only public surface added is the optional GateEntryRef type and the gate:wake-line-omitted trace event. No new dependency, service or platform is added. (src/gate/types.ts, src/types/trace.ts)
  • The PR is shaped the way CONTRIBUTING.md asks. The body has Problem, Changes, Tests and Not verified sections and gives numeric test baselines against main. It adds a changed fragment in changelog.d/ and includes tests that it says fail on main. It is one coherent change. (CONTRIBUTING.md (What a PR should contain; Changelog))

Findings

1 finding of medium or high severity, see the inline comment:

3 more findings, less severe
  • Low severity, test coverage, test/batched-wake-line.test.ts:245-260. The 'waiting on its tools' case of turnAlive is not exercised. isNewestContextEntry returns false when turnAlive(agent) is true. turnAlive covers activeTurnTokens, activeStreams and pendingAssistantBlocks (src/framework.ts 15892-15896). The test 'false while a turn is alive (started, or streaming)' sets only the first two. Nothing checks that a turn paused mid-tool-cycle (pendingAssistantBlocks) keeps the line, and the doc comment names that case. Add a third toggle on pendingAssistantBlocks.
  • Low severity, test coverage, src/gate/event-gate.ts:1649-1665. Line omission on the buffered and policy-reload delivery paths is untested. deliverEvents is also reached from flushInferenceBufferIfClear, when a batch buffered during a turn or quiesce is delivered after the turn ends (line 1748), and from clearPolicyState (line 1320). The new omission logic now applies on those paths too. After a turn, the agent's reply or a flushed deferred write decides whether the line is written. No unit or end-to-end test delivers a one-event batch through the buffer and checks the result, so the PR's claim that 'a turn running' keeps the line is only checked at the framework callback, not through the gate's buffer path.
  • Low severity, maintainability, src/gate/event-gate.ts:1680-1694. A throwing isNewestEntry callback is swallowed with no log or trace. newestEntry catches any exception from isNewestEntryFn and returns false, and records nothing. The fallback (keep the line) is safe. But if isNewestContextEntry starts throwing in production, for example because of a context-manager API change around queryMessages({ limit, reverse }), the feature quietly turns itself off. Nothing would show it except the lines coming back. Emit a trace or a one-time console.error in the catch, so the fault can be seen.

Comment thread test/event-gate.test.ts
Comment on lines +1238 to +1263
});

it('one channel message, still newest: no line, the wake still runs, and the framework is asked about that message', () => {
const asked: GateEntryRef[] = [];
const { gate, messages, inferenceRequests, traces } = makeGate(config('quiet-one.json'), {
isNewestEntry: (entry) => { asked.push(entry); return true; },
});
gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:g:room', content: 'hi',
metadata: { messageId: 'm1' }, tags: ['chat:addressed'] }));
fire(gate, 'chat');
assert.strictEqual(messages.length, 0, 'no [Gate: …] line');
assert.strictEqual(inferenceRequests.length, 1, 'the wake runs');
assert.deepStrictEqual(asked, [{ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:g:room', messageId: 'm1' }]);
assert.ok(traces.some((t) => t.type === 'gate:wake-line-omitted'));
});

it('one push event, still newest: no line, asked by its event id', () => {
const asked: GateEntryRef[] = [];
const { gate, messages, inferenceRequests } = makeGate(config('quiet-push.json'), {
isNewestEntry: (entry) => { asked.push(entry); return true; },
});
gate.evaluate(event({ eventType: 'mcpl:push-event', serverId: 'discord', channelId: '1548', content: 'dm',
metadata: { eventId: 'ev-1' } }));
fire(gate, 'chat');
assert.strictEqual(messages.length, 0);
assert.strictEqual(inferenceRequests.length, 1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity, test coverage. The 'no line' unit tests never check that the wake's provenance is unchanged.

The PR says that when the line is left out, requestInference and its provenance (and so the reply's channel) are unchanged. The two 'still newest' tests only check inferenceRequests.length === 1. They do not check the channelId, routeChannelId, counterparty or addressed fields that wakeProvenance(events) passes. A later refactor of deliverEvents that moved provenance into the line-writing else branch, or built it differently on the new branch, would send the reply to the wrong channel and still pass. Compare the request's provenance fields with the same batch delivered with isNewestEntry: () => false.

@antra-tess
antra-tess merged commit 8843507 into main Oct 23, 2026
12 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 23, 2026
main brings anima-research#710 (two servers' channels with one id told apart), anima-research#727
(a batched wake writes its line only when it says something), anima-research#743,
anima-research#744 and the 0.26.0 release. The one conflict is test/event-gate.test.ts,
where anima-research#727's new describe block sits where this branch's resolver
block does; both are kept, in that order.

src/gate/event-gate.ts and src/gate/types.ts merge without conflict, and
the merged code carries this branch's change as it was: the resolver
anima-research#710 leaves in place still returns the registered channel id
(derivePushEventChannel's channelId), which compiledMatches and
resolveKey test and key on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 23, 2026
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 23, 2026
…itten line is traced

The automated review of 4dd8064: every agent but the subconscious gets
every batched wake (the gate's getAgentNames), so on a multi-resident host
a co-resident's start, which wrote the line whatever was newest, put it
either right under the message, the line anima-research#727 left out, or after the
delivery agent's answer, a wake line for a message already answered.

- placeWakeLines acts on the delivery agent's own start only. The line is
  then written once, at the start of the agent the window belongs to,
  before its answer; that start's token is also the one that holds
  arrivals off the window until its compile. A co-resident's request shows
  what the slot holds, as on main: a reply after the message is in the
  message's own channel, so nothing there misdirects.
- A written line is traced message:added, source gate:wake-line, as the
  framework's other direct writes are.
- The fragment and the docs say a co-resident places none, and that the
  newest check passes over a clock row after the message, at the fire too.

Tests: one wake for two residents through real turns, both orders: the
co-resident first writes nothing and the delivery agent's start writes
the line once, after her reply and before its own answer; the delivery
agent first leaves it out as newest and the co-resident's later start
writes none after its answer. The written-line test asserts the trace.
90 of 90 in the two files; thirty-one removals each fail a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 23, 2026
… when the batch fires

#727 leaves out a batched wake's `[Gate: …]` line when its one message is
the newest entry in the window as the batch fires. The turn compiles later,
and a message stored in between (a provider hold, a compression call in
flight) puts the wake's message above it with no line saying which message
the wake is for. The reply still goes by the wake's provenance, to that
message's channel, so an agent reading the last entry as its cause answers
the other message into the first one's channel. At main 8843507, on the
end-to-end rig, a message stored right after the fire gives a request that
ends "DM, later message" with no line.

The decision moves to where the request is fixed:

- EventGate.deliverEvents: for a one-event batch still newest at the fire
  (isNewestEntry, unchanged), the gate hands the line to the wake as a
  WakeLine (its event's entry, policies, the channel as the line names it,
  the event's arrival time) on WakeProvenance.line, one object shared by
  every agent the batch wakes. Other batches write their line at once, as
  before, through the same formatter (referenceLine). wakeLineText renders
  a handed line when it is written, so its age is true then.
- The request carries it (InferenceRequest.wakeLines). runInferencePass's
  merged trigger keeps every merged request's lines, one per event
  (mergeWakeLines, wakeLineKey); the quiesce parking merges an absorbed
  wake's lines into the kept one; sameInferenceRequest compares two held
  wakes' lines.
- beginAgentTurn, on a fresh start (attempt 0, not resuming a logical
  turn), after the turn's own inputs (a due clock row, the turn-start and
  assembly flushes) and before its checkpoint, calls placeWakeLines for an
  agent that reads the delivery agent's window (residentHoldsTraffic):
  - on the delivery agent's own start, a trigger's one line whose message
    is still the newest entry (a clock row after it aside, as the start may
    just have written one; isNewestIn) is left out: its token defers
    arrivals past this request. Otherwise the lines are written, after what arrived and
    before the start's own notices;
  - a co-resident's start writes its lines whatever is newest, since its
    token doesn't hold arrivals off the slot;
  - no start writes one while the host is quiesced or another reader of the
    slot has a turn alive (slotHeldFrom: the quiesce, then sharedSlotBusy,
    the rule a clock row already followed; storeDueClock now calls it too);
    that request goes without it;
  - a line left out stays on the trigger, for a later fresh start of the
    wake (a provider hold's release) to decide again, and is traced
    gate:wake-line-omitted with its reason (newest or held);
  - each line is written once (placedWakeLines, by its identity), whichever
    reader starts first; a write that throws doesn't fail the start, and
    one that landed though it threw is read back and counts as written.
- A retry decides nothing: its line would land after the first attempt's
  own notices or rounds.

Not changed: the fire-time check and its guards, the line's words and
metadata, a tuned-out message's line (written at the fire), forks and
ephemeral runs (they place none).

Tests: test/batched-wake-line.test.ts and test/event-gate.test.ts, 90 of
90: the race end to end, a retry end to end, merges, quiesce, holds,
co-residents, the identity of the handed line through the gate and the
wiring, write failures, the age and the label. Thirty-two removal pieces each
fail at least one of them. At main, the race test fails on behaviour.

Changelog: #727's pending fragment says when the line is decided;
gate-wake-line-handed-to-the-wake.added.md lists WakeLine, wakeLineText,
WakeProvenance.line and InferenceRequest.wakeLines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 24, 2026
…qualified channels meet the registry, tune-out and the reply anchor

What main brings since e4d1e46, the sixth catch-up's base: 0.25.0 and
0.26.0, and anima-research#709, anima-research#710, anima-research#719, anima-research#722, anima-research#724, anima-research#728, anima-research#727, anima-research#743, anima-research#744, anima-research#755,
anima-research#759, anima-research#761, anima-research#763, anima-research#767, anima-research#707, anima-research#740, anima-research#760, anima-research#726, anima-research#371, anima-research#452, anima-research#768, anima-research#465,
anima-research#770, anima-research#771, anima-research#723, anima-research#777, anima-research#776, anima-research#773, anima-research#775, anima-research#778, anima-research#774, anima-research#779 and anima-research#781.
Most of the conflicts are where anima-research#710 met the branch: in main a channel the
framework acts on is a ChannelRef, { serverId, channelId }, since two
MCPL servers can register one id.

Conflicts in 14 files (61 hunks), resolved by keeping both sides' intent;
where the two had different designs for one thing, the branch's stays,
moved onto main's types:

- src/framework.ts: main's conversationAgentHomes map stays gone. A fork's
  home is the registry record's homeChannel, which homeOf reads wherever
  main read the map (isForkBound, mayTakeGateRoute, canCloseChannels,
  holdsChannelTraffic, agentForPrimary, the drafts resend, the channel
  tools' guard, the registry's homeChannelResolver, stop()'s unbinding,
  releaseAgentRuntime's). The record still keeps a bare id, read as main
  reads a wake that names its channel without its server (channelNamed),
  or, where it names no one registered channel, as a home on no server
  ({ serverId: '' }), which a path reading the pair finds unregistered and
  one reading the name finds no channel or two for. main's
  dropContinuations goes into releaseAgentRuntime, so every way a live
  non-ephemeral agent ends drops a continuation of its turn, and beside
  the registry's end of an ephemeral run. Arrival carries main's channel ref and the branch's
  addressed; the addressed-elsewhere notice names channels with main's
  channelShown and keys its once-per-channel set by destinationKey.
  isAddressedMessage stays tag-only, with main's ambient marks.
  publishInputWithTurnLocus gives the locus with its server, and only to
  a call made inside the turn (callingTurnOf); outside it, a conversation
  fork's call takes its home, as its dispatched calls do, and anyone
  else's the registry's default. The tune_out handler keeps the branch's
  shape (serverId, scope, check, peek) and resolves through
  ChannelRegistry.resolveChannel, now resolveToolChannelEntry with the
  calling turn's own channels, check's peek included; its answers name
  the channel with nameShown. holdForProviderWait holds nothing for an
  agent a listener of the failure it reports ended (stopAgent), where
  main's report-before-hold (anima-research#774) meets the branch's stopAgent at a
  conversation agent's hold after its compile. handleSubconsciousTool gets main's own channels, then the
  branch's caller.
- src/tune-out/coordinator.ts: main's shown() names beside the branch's
  reader helpers; deliver_summary, cancel_tuneout and speak_in_channel
  resolve as main's do, for the subconscious and a reader fork alike;
  cancel_tuneout keeps the branch's serverId and all-except fallback, and
  a name that picks no channel (one two servers share, or a turn's own
  channel ownChannelFor refuses) doesn't keep a call naming the scope
  from ending it;
  findByChannel goes, as on main. focusChannel() returns a ref.
- src/mcpl/channel-registry.ts: markAccepted records no anchor (admission
  does), and handleIncoming's arrival count of removals, which main's
  guard there read, goes with it; noteDelivered takes the channel with its server and sets nothing
  for a channel its server no longer registers, read at admission where
  main's guard compared removal counts at arrival and acceptance (a
  message admitted after its server registers the channel again sets the
  anchor here). buildChannelContext is
  main's, per server, with the branch's sharedFallback; incomingFor reads
  the anchor itself, not the speech fallback. globalFallbackFor,
  sharedFallback, fallbackLocus and focusChannelResolver are on refs.
- src/index.ts: main's DefaultErrorPolicy beside the branch's exports.
- Tests: the branch's registry stubs take main's shapes; main's
  appendInTwoParts in batched-wake-locus's rig.

Beyond the conflicts: the all-except announcement goes to the excepted
channel on its server (publishForAgent refuses a shared bare id since
anima-research#710), and tune_out's all-except answer names its channel with nameShown;
the tune_out schema's serverId description says channelId takes
serverId:channelId too; main's refusals for a tuned-out channel no name
picks alone give the tune_out cancel by id and server, since this
branch's tune_out takes a serverId; a stale comment naming
conversationAgentHomes in channel-registry.ts names the registry;
docs/dendrite.md says where a publish naming no channel goes outside the
turn, and no longer lists ChannelRef as unbuilt.

Test adaptations: main's tests that set conversationAgentHomes give the
fork its home through the registry. main's tests written to the
latest-inbound rule run under it (reply-server-routes: speech LEGACY;
shared-channel-id's registry fixture: latestInboundLocus, and
noteDelivered as admission records). Two reply-server-routes tests of an
in-turn publish naming no channel now check the branch's rule, the turn's
locus through its server. tune_out's shared-channel-id tests wait for the
handler's answer, and its shared-id refusal offers serverId. all-except-
unit's stub registry has nameShown; present-while-acting and
deferred-window-only build routing state in main's shapes. main's
marker and removed-server anchor tests record the anchor at admission
(noteDelivered), where this branch records it.

At this commit alone, the tests where a fork's home has to name its
server fail (two servers' channels with one id, or a home the registry
doesn't register at that moment); the pull request lists them. The next
commit stores the home with its server.

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