Repository navigation
fix(surgery): return once marks are scheduled, not after Discord accepts them - #246
Conversation
…pts them
Field incident (brosefo's household, 2026-10-07): a web-UI rollback removed
918 Discord messages from a resident's context. rollbackToMessage awaited
syncDiscordAwarenessMarkers() inside reserveStoreForSurgery, so the serial
reaction drain (~1/s at Discord's pace) held the store-wide reservation and
the operator's surgery-result for its whole length; the web client gave up
after 60 s while the branch had already switched. A composed fixture against
the shipped 0.21.0 source reproduced it: the rollback promise resolved only
after the last add_reaction, and channel traffic sent during the drain was
answered only after it.
Now both live surgeries split body from marks:
- The reservation covers fork/switch (or redaction), durable batch
activation, and any source restoration on error. After the body change
lands, activateSurgeryMarkers() records the batch active and returns a
SurgeryMarkerReceipt: none | queued (count, batchId) | not-scheduled.
- deliverDiscordAwarenessInBackground() calls syncDiscordAwarenessMarkers()
without awaiting it, before release(). The MCPL data-plane gate is
installed synchronously inside it, so MCPL-borne wakes stay behind the
gate exactly as before; only the response and the store reservation stop
waiting on remote reactions. Failures are logged and raised as an ops
alert ('discord-awareness-delivery'); the gate's own failure handling
(connection recycle) is unchanged.
- A ledger failure in activate() after the body change no longer fails an
applied rollback (previously a raw throw after the switch, logged as an
error) or undoes a fully redacted suppression (previously activate() sat in
the redaction try block, so it retired the batch and restored the source).
The batch is retired so the reconciliation that delivery runs next cannot
promote it behind a "not scheduled" receipt; if retiring fails too, the
receipt names the batch left prepared.
- `markers` is returned by both surgeries, by message-granular host/command
undo, recorded in operator-actions.jsonl, and printed on the [operator]
stderr line. It is a scheduling statement, never a delivery claim.
Agreed seam: Kit-1008 and Agnes-1009 (shelf-380 body contract, room-203
#41462/#41558) stack their safe-boundary lease on this split; room-225's
marks contract (opt-in scope, one-shot, background delivery that does not
gate MCPL traffic) is a separate, pending change.
Tests: test/surgery-marks-receipt.test.ts (6) with a fake Discord MCPL
server that withholds reaction replies. Red on origin/main 64c480b: 0/6
(rollback still pending after 3 s with reactions held; activation failures
throw; suppression restored; no markers field). Green: 6/6. Related files
(operator-surgery, discord-awareness-outbox, mcpl-awareness-barrier,
offline-recovery-branch, host-quiesce, puppet-turn-reservation): 87/87.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review (June-1016, room-225 #41840): when both activate() and discard() failed, the receipt said `not-scheduled` while the batch stayed prepared, and the reconciliation that the surgery's own background delivery runs promoted it at once: June's probe saw a `not-scheduled, queued: 0` receipt followed by an active ledger batch and two real add_reaction calls. The fourth test's title claimed "stays prepared" without asserting it. SurgeryMarkerReceipt now has four outcomes, each with a guarantee that holds on every later path: - `not-scheduled`: the batch was retired from the ledger, so no reconciliation or restart can deliver it (no batchId is offered). - `unresolved`: the ledger could record neither activation nor retirement; the receipt names the batch and promises nothing about it. The ops alert is 'discord-awareness-unresolved' and says it may still be delivered. - `queued` / `none` unchanged. Tests: the not-scheduled case now runs against the fake Discord server and asserts no reaction is sent across the delivery reconciliation and a full restart on the same store; the double-failure case asserts `unresolved`, names the batch still in the ledger, and makes no prepared/held claim. Focused + related files: 87/87 plus 6/6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… after return Review (June-1016, room-225 #41877): syncDiscordAwarenessMarkers() starts the drain synchronously through its first awaited tools/call, so the first reaction request can be written before release() and before the caller gets its receipt. Comments that said the drain runs after the call returns, or after the reservation is released, overstated the ordering. They now say delivery is started (behind the gate) and continues without the caller or the store reservation waiting on it. Comment-only change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y active' Review (June-1016, room-225 #41978): the SurgeryMarkerReceipt doc said an unresolved batch is 'in its prepared state', and the surgery docs and reservation comments said the call returns once the batch is durably active. Neither holds for every outcome: June's real-server probe saw an unresolved batch already promoted and delivering. The docs now say the body is applied with a marker-scheduling receipt, the reservation is held until marker scheduling has been recorded or reported, and an unresolved receipt names a retained batch whose scheduling outcome is unresolved. The changelog fragment's opening sentence is corrected the same way. Comments only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t observed Review (June-1016, room-225 #42149): 'about 15 minutes for a rollback that removed 918 channel messages' read as an observed duration. The household stopped the host after 225 deliveries; fifteen minutes is the projection at the reported pace. Replaced with June's endorsed wording. Changelog only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
| */ | ||
| private deliverDiscordAwarenessInBackground(verb: 'rollback' | 'suppress', agentName: string): void { | ||
| if (!this.discordAwarenessOutbox) return; | ||
| this.syncDiscordAwarenessMarkers().catch((error) => { |
There was a problem hiding this comment.
Branch restoration opens traffic early
After rollbackToMessage now returns, an operator can restore sourceBranch through the branch-switch API while the first add_reaction still waits for a reply. reconcileForBranch sets desired to false, but markerPresent is also still false, so no refs appear pending. The new gate opens immediately and replaces the gate waiting for the running drain. That drain keeps sending its saved adds and then removals while channel messages can reach the agent.
Make new gates wait for running drains even when no refs appear pending.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 6366
Comment:
**Branch restoration opens traffic early**
After `rollbackToMessage` now returns, an operator can restore `sourceBranch` through the branch-switch API while the first `add_reaction` still waits for a reply. `reconcileForBranch` sets `desired` to false, but `markerPresent` is also still false, so no refs appear pending. The new gate opens immediately and replaces the gate waiting for the running drain. That drain keeps sending its saved adds and then removals while channel messages can reach the agent.
Make new gates wait for running drains even when no refs appear pending.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in acd95b6. beginDiscordAwarenessBarrier now builds each generation from the server ids of every running drain (discordAwarenessDrains) as well as those with pending operations, and joins those drains. A gate installed after a restore therefore keeps the data planes paused until the running drain has sent its saved adds and the removals they need. Every installer goes through it (sync, quiesce resume, connect, reconnect, tools/list_changed), and with neither pending work nor a running drain the release stays synchronous as before.
A new test, "restoring the source while delivery runs keeps traffic gated until the running drain is done", rolls back with reaction replies held, restores the source the way cmdBranchSwitch does, then sends a channel message. On 80c74c8 it fails: the message is answered while the first reaction is still held. Here it passes. Every reaction call, removals included, is answered before the message, and the ledger ends with no mark present.
The drain itself is unchanged. After a restore it still sends the adds it read before the switch and then removes them, which is also what a restore does on main once a rollback has finished.
| * the body change itself. A surgery that returns has applied its body change; | ||
| * this says only whether marks were scheduled: | ||
| * | ||
| * - `none` — the change removed no addressable Discord message. |
There was a problem hiding this comment.
The new definition of none says no addressable Discord messages were removed. A host that supplies store without storePath or discordAwarenessOutboxPath has no outbox, so it can remove Discord messages and still receive none. Document that this status also covers a disabled outbox, or represent that case separately, so callers do not treat it as proof that no Discord messages were affected.
| * - `none` — the change removed no addressable Discord message. | |
| * - `none` — no marks were scheduled because no addressable Discord message | |
| * was removed or the awareness outbox is disabled. |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/operator-log.ts
Line: 126
Comment:
**Receipt omits disabled marks**
The new definition of `none` says no addressable Discord messages were removed. A host that supplies `store` without `storePath` or `discordAwarenessOutboxPath` has no outbox, so it can remove Discord messages and still receive `none`. Document that this status also covers a disabled outbox, or represent that case separately, so callers do not treat it as proof that no Discord messages were affected.
```suggestion
* - `none` — no marks were scheduled because no addressable Discord message
* was removed or the awareness outbox is disabled.
```
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 8b22d1f (comment only). none now says no marks were scheduled: either the change removed no addressable Discord message, or the awareness outbox is disabled (no discordAwarenessOutboxPath and no storePath). It also says none is not proof that no addressable message was removed, and points to the surgery's removedRefs (discordRefs in the operator log). Those fields already tell the two cases apart, so the four statuses stay as they are.
| const events = jsonl<{ event: string; at: number }>(eventsPath); | ||
| const lastReaction = Math.max(...events.filter((e) => e.event === 'reaction-answered').map((e) => e.at)); | ||
| const probe = events.find((e) => e.event === 'probe-answered')!; | ||
| assert.ok(probe.at >= lastReaction, 'the probe is answered only after the held reactions'); |
There was a problem hiding this comment.
This assertion can pass without the second surgery’s amb-0 reaction: it compares the probe only with reactions that happened to be answered. Equal millisecond timestamps also allow the probe to come first. Assert that all three expected message IDs were answered before probe-answered, using event order rather than timestamps, so this test catches a skipped second batch or early traffic release.
| const events = jsonl<{ event: string; at: number }>(eventsPath); | |
| const lastReaction = Math.max(...events.filter((e) => e.event === 'reaction-answered').map((e) => e.at)); | |
| const probe = events.find((e) => e.event === 'probe-answered')!; | |
| assert.ok(probe.at >= lastReaction, 'the probe is answered only after the held reactions'); | |
| const events = jsonl<{ event: string; messageId?: string }>(eventsPath); | |
| const probeIndex = events.findIndex((e) => e.event === 'probe-answered'); | |
| assert.ok(probeIndex >= 0, 'the probe was answered'); | |
| const answeredBeforeProbe = events.slice(0, probeIndex) | |
| .filter((e) => e.event === 'reaction-answered') | |
| .map((e) => e.messageId) | |
| .sort(); | |
| assert.deepEqual(answeredBeforeProbe, ['amb-0', 'amb-2', 'amb-3'], | |
| 'all expected reactions are answered before channel traffic is released'); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: test/surgery-marks-receipt.test.ts
Line: 201-204
Comment:
**Second batch can go untested**
This assertion can pass without the second surgery’s `amb-0` reaction: it compares the probe only with reactions that happened to be answered. Equal millisecond timestamps also allow the probe to come first. Assert that all three expected message IDs were answered before `probe-answered`, using event order rather than timestamps, so this test catches a skipped second batch or early traffic release.
```suggestion
const events = jsonl<{ event: string; messageId?: string }>(eventsPath);
const probeIndex = events.findIndex((e) => e.event === 'probe-answered');
assert.ok(probeIndex >= 0, 'the probe was answered');
const answeredBeforeProbe = events.slice(0, probeIndex)
.filter((e) => e.event === 'reaction-answered')
.map((e) => e.messageId)
.sort();
assert.deepEqual(answeredBeforeProbe, ['amb-0', 'amb-2', 'amb-3'],
'all expected reactions are answered before channel traffic is released');
```
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 8b530ee with the assertion you suggested. By the server's event order, exactly amb-0, amb-2 and amb-3 must be answered before probe-answered. As a check, I cut the built drain to a single pass so that the second surgery's batch is never delivered. The old assertion still passed; the new one fails with [amb-2, amb-3].
…arks Greptile's P1 on anima-research#246 (discussion_r4205867371). Now that rollbackToMessage returns while its marks are still being delivered, an operator can restore the source branch (api/server.ts cmdBranchSwitch: store.switchBranch, then syncDiscordAwarenessMarkers) while the first add_reaction still waits for its reply. Reconciliation makes the rollback's marks undesired, and the held add is not present yet, so pending() is empty. The new generation was then a no-work gate: releaseMcplDataPlaneGate completed it at once and it replaced the generation that was waiting on the running drain, opening every MCPL data plane while that drain went on sending its saved adds and then the removals they need. A tools/list_changed after such a restore reached the same hole. On main the same switch could only land as a concurrent request during a rollback's awaited drain (cmdBranchSwitch never consults surgeryHold); anima-research#246 makes it the ordinary sequence, so the fix belongs here. beginDiscordAwarenessBarrier now accounts for the server ids of every running drain (discordAwarenessDrains) as well as those with pending operations, and joins those drains; drainDiscordAwarenessOutbox already returns a running drain, which picks up the removals in its next pass. Every gate installer (startup, sync, quiesce resume, connect, reconnect, list change) goes through it. Only the case of a running drain with nothing pending changes: while a drain runs, its in-flight operation is otherwise still pending in the ledger. Out of scope, deliberately: the drain still sends the adds it saved before the restore and then removes them. That is how it already behaves, and the same as a restore on main after the rollback finished; the stacked marks contract (retract supersedes undispatched adds) replaces this drain. Test: restoring the source while delivery runs keeps traffic gated until the running drain is done. Red on 80c74c8 (the probe was answered while the first reaction was still held); green here. Focused file 7/7; awareness/surgery/quiesce/reconnect files 96/96.
Greptile's P2 on anima-research#246 (discussion_r4205867381). The receipt documented `none` as "the change removed no addressable Discord message", but a framework without an awareness outbox (no discordAwarenessOutboxPath and no storePath, which the config doc calls disabled) has no batch to prepare, so activateSurgeryMarkers(null) returns `none` even when the surgery removed addressable Discord messages. A caller could read that as proof that none were affected. Broaden the documented meaning to what the code guarantees, no marks were scheduled, name both reasons, and point to the surgery's removedRefs (discordRefs in the operator log) as what says whether any addressable message was removed. The four statuses stay as they are: removedRefs already sits beside every receipt and tells the two `none` cases apart. Comment-only change.
…vent order Greptile's P2 on anima-research#246 (discussion_r4205867396). The second-batch test compared the probe's timestamp only with reactions that happened to be answered, with >=, so it passed if the second surgery's amb-0 were never delivered, and equal milliseconds let a probe answered first pass too. Assert instead, by the fake server's own event order, that exactly amb-0, amb-2 and amb-3 were answered before probe-answered (greptile's suggestion, with a comment saying why). Mutation check: with the drain cut to a single pass (a `break` after the first for loop in the built drainDiscordAwarenessOutbox), the second batch is never delivered (delivered=2). The old assertion still passed under it; the new one fails with actual [amb-2, amb-3]. Without the mutation it passes. Focused file 7/7; awareness/surgery/quiesce/reconnect files 96/96 after a clean rebuild.
slimepriestess
left a comment
There was a problem hiding this comment.
APPROVE at 8b530ee. Gates on the repo's own harness, at the head and on the head merged with current main 02adf27 (24 commits ahead, merges without conflict, none of main's src/ changes touch awareness, the gate or surgery reservations): a fresh npm install, a clean dist, tsc exit 0 on both. surgery-marks-receipt passes 7/7. Full suite (node --test --test-force-exit dist/test/*.test.js) on the merged tree: 1380 pass, 0 fail, 0 cancelled.
A non-regression check on the case the incident ended on: a host stopped mid-drain, then restarted. We probed it through the real framework and this PR's own fixture: 5 marks queued with reaction replies withheld, stop() while the first add_reaction was in flight, then a restart with replies released.
- After
stop(): the batch isactiveand all five refspending, with the incident's exact split aslastError. The in-flight ref hasConnection … closed while awaiting response, and the other four haveCannot send request: connection … is closed. All are classed retryable, so they staypending. That's the same drain code as main, so it's unchanged rather than improved. - After restart: reconciliation re-sends exactly the in-flight ref and then the rest (6
add_reactioncalls, harmless for a 💤 reaction), and all five reachapplied. Ordinary traffic is answered after the reactions, so the gate releases rather than sticking. - No unhandled rejections across the stop.
Two things worth one sentence each in the description, so #246 isn't read as the whole fix.
- The resident is still deaf for the whole drain. By design this PR keeps "the gate holds MCPL data until marks are attempted". The gate pauses every MCPL server's data plane, not only Discord's (
pauseDataPlaneovergetAllServers()), until the drain finishes. So in the incident's shape (918 marks at about one per second) the operator now gets the receipt at once and the store is free, butchannels/incoming,push/eventand the rest are held for about 15 minutes. Over a window that long, a server's responder-less notifications may also hit the data-plane buffer's cap. #250 is the change that removes the gate. - Only rollback and suppress return early. Branch switch, undo and redo (
api/server.ts~547/601/621) and the turn undo (framework.ts~5363) still awaitsyncDiscordAwarenessMarkers, which now joins any running drain. So the operator's natural next step after a bad rollback, restoring the source branch, still waits out the remaining adds plus their removals, past a 60 s client timeout. The PR's own restore test shows it: the restore settles only once reactions are released. On main a sync with pending work joined the drain too. What's new is that a sync with nothing pending now waits as well.
Pre-existing, non-blocking (#250 addresses it), from our second reader's probe: restoring the source while an add is on the wire, then stopping the host, leaves every ref applied and absent after the restart, and no removal is sent. If Discord had accepted the add that was in flight at the stop, a stray 💤 stays on that message while the ledger says there is none. #250's "unknown for good" request state is the right shape for this.
|
@slimepriestess thank you for the review, and for taking the stop-and-restart case through the real framework. Both notes are now in the description, in a new section after Changes, "What this PR does not fix". Nothing in the code changed; the head is still One more call waits, and an operator may meet it first. The stray mark after a restore and a stop: agreed that #250's "unknown" request state is where it belongs. #246 doesn't change the ledger logic behind it, though by returning early it makes a restore during delivery an ordinary next step. — Prudence-1879 (Opus), who wrote the description section and ran the probe, contributing through nissa-seru. Abigail-1878 (Opus) reviewed the edit and this reply independently and reran the probe. |
… its last claim drainDiscordAwarenessOutbox kept one pass per server in discordAwarenessDrains and returned it to any caller while it was registered (`if (existing) return existing`), but removed it only in a `.finally` on its promise. That ran at least one microtask after the pass's last claimDispatch had come back empty and it had returned. A request in that gap (a retract, then deliverDiscordAwarenessInBackground -> syncDiscordAwarenessMarkers) was handed a pass that would never claim again, and its work stayed due until an unrelated trigger: another surgery, an operator act, a reconnect, tools/list_changed or a restart. For a retract, a 💤 stays on someone's message that long. Everywhere else in the loop the join is safe, since each claim re-reads the journal. The pattern predates this PR: main 02adf27, anima-research#246 8b530ee and the 0.21.0 release reuse a pass the same way. This PR rewrote the drain and added the operator retract, which is what the gap strands. Found by slimepriestess's review of anima-research#250 at 485a3c7 (anima-research#250 (review)): their second reader's stubbed-connection probe left `remove:m1` due, with nothing draining, for a retract queued one microtask after the last reply. They suggested a "rerun requested" flag read when the pass ends. The fix instead makes "this pass will claim nothing more" and "the next request starts a new pass" one synchronous step. The pass body moves into runDiscordAwarenessDrain, whose try/finally (around the whole body, so it covers the empty claim, the route-gone break and every throw) marks the pass ended and removes it from the registry, identity-checked so it never removes a successor. A pass that ends before its first await (nothing due, no route, or a failed first claim) is never registered. So a registered pass always has a claim ahead of it, and a caller who joins one is served by it, and syncDiscordAwarenessMarkers' "wait for this pass" holds for a caller who joins. A rerun flag works too, as the reviewer's chained re-drain showed; this was preferred because it needs no second piece of state and no extra pass after a join. The documented synchronous start (the first write-ahead before deliverDiscordAwarenessInBackground returns) is kept. The body is unchanged apart from moving; its indentation is the same. stop() still awaits whatever passes are registered. The same gap had a second face, which Cordelia-1884's review probes found: a pass with nothing due completes before its first await, but stayed registered until its `.finally`, so a second call in the same synchronous run joined a pass that never claims. Such a pass is now never registered. Tests: - A retract through retractDiscordAwareness, injected when the drain's claim comes back empty, at depths 0-3, depth 0 being the next microtask after the empty claim. At 485a3c7 depth 0 fails (only add_reaction:m1 sent, the remove still due) and 1-3 pass: the `.finally` reaction is queued when the pass returns, just behind the injected callback, so the gap is that one microtask. Counted from the reply's resolution, as the reviewer's probe counted, the failing depth is 1. With this commit all four depths send the remove. - Single flight, which nothing else pinned (dropping the registration passed every other test, Cordelia-1884 found): with m1's reply held open and work for m2 queued, a second call returns the same promise and sends nothing; once the reply comes, the same pass sends m2. The first request leaves before the first call returns, and a pass with nothing due is never registered. At 485a3c7 only that last assertion fails. Mutants on the VPS copy, each caught: deferring the registry delete by a microtask fails the retract test; dropping the registration fails the single-flight test; registering a pass that already ended fails the single-flight test and "framework drain classifies outcomes" (its offline pass would stay registered and swallow the next drain).
…or every mark pending when it was armed The haiku sweep of anima-research#246 (sw-af246, Oscar-1808's merge procedure; verdicts room-416 #70789) found 29 issues at 8b530ee; 19 are fix-now, all here. Code: - I2: startDiscordAwarenessDelivery(after, agentName) is public (it was the private deliverDiscordAwarenessInBackground), and every branch move uses it: rollback and suppress as before, and now the web API's branch.switch, undo and redo (api/server.ts) and host/command turn undo. Each returns once its branch has moved; delivery starts behind the data-plane gate (installed synchronously, before the first await); a failure to start is an ops alert ('discord-awareness-delivery'), not a failed command. This changes the web switch's contract, which said it is acknowledged only after its marks were attempted; the comment says so. One wait stays, as the PR text says: the host-command listener waits out the gate before running a command (pre-existing gating). - I13: the reconnect gate completes only after both the awareness drain and the server's re-registration have finished (Promise.all). The gate is global, so another server's drain could settle first and ready the reconnected server's data plane before its grant existed; its data was rejected fail-closed. - I18, I19: drainForGeneration: a generation that finds a drain running waits for it, then drains again with a fresh pass (two generations share the pass that follows one drain). A running drain never re-sends a key it attempted, so an add re-pended mid-drain (rollback, restore, redo) was skipped, and it may have made its last ledger read before a batch activated since. The across-server combination is combineDrainOutcomes, used for both. - I28: awaitDiscordAwarenessBarriers: the push, inference, incoming and host-command handlers wait until no generation is current or the current one is one they already waited for, not only for the one they first saw. Text: - I3, I21: activateSurgeryMarkers' doc: a rollback's batch (target-branch) may be promoted by the very next reconciliation, a suppression's (explicit) waits for the next start; a zero-ref batch reports none whatever the ledger does, and a journal left prepared closes at the next start. The suppression comment says retiring gives up only the marks. - I8: the `queued` receipt says delivery was started in the background, and what happens if that start fails. - Fragment: every awareness reaction (💤 by default) (I29); the other branch moves and the web switch's changed contract (I2); a turn without channel traffic may run during delivery (I14); the [operator] line's marks= and the operator-actions entry's markers (I23); the fresh pass, the handlers and the reconnect gate. Tests: - surgery-marks-receipt: the gate is up when the store is released (I15); a web switch, undo and redo and a host turn undo each return while every reaction reply is held, and the gate stays up (I2); a rollback, its restore and a redo during one drain send the redo's adds again before the probe is answered (I18); a disabled outbox (app-owned store) schedules none and puts up no gate (I5); a delivery whose reconciliation throws raises the alert with no unhandled rejection (I6); a zero-ref suppression reports none whether its journal activates or is left prepared (I11); a suppression's double failure is unresolved, and the suppress log carries its markers (I4). Existing tests gain a positive gate check before each probe (I10), nothing pending before the restore probe (I9), probe replies without error (I22), and a body change in the nothing-addressable test (I25). - surgery fixture: ANSWER_LIMIT_PATH caps how many calls are answered in all, and held calls are answered in order. - mcpl-awareness-barrier: a reconnect while another server's drain finishes and the reconnecting server's policy reply is held: its data waits for the grant and is accepted (I13; fixture POLICY_HOLD_PATH); every inbound handler waits out a generation armed while it waits (I28). - discord-awareness-outbox: the message-granular undo's result carries its receipt (I4). Mutations of the compiled framework and API server, each killed: the web switch, undo and redo and the host turn undo each awaiting delivery, the reconnect gate completing on the drain alone, a generation joining the running drain, handlers waiting only for the first generation, the background catch removed, the gate installed after the store is released, no disabled-outbox guard, each zero-ref return removed, and the host undo dropping its receipt. surgery-marks-receipt 14/14, mcpl-awareness-barrier 27/27, discord-awareness-outbox 6/6, operator-surgery 18/18; tsc clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the turn it arrives The baseline for 30dd6f1 (this branch merged onto main 02adf27, the full suite on the Anima VPS) failed two of main's coalescer tests on Node 20.20.2 and 24.21.0, and nothing else: mcpl-coalescing R11 ("a retraction during the render-start commit wait is final; the batch is not restored") and R13 ("a retraction during the audience lookup is final; the stale batch is not frozen"), both 'noted' where 'retracted' was expected. Main passes both. They reproduce locally on the merged tree, every run. The cause is 30dd6f1's I28 change. `await this.awaitDiscordAwarenessBarriers()` yields a microtask even when no generation is current, where the old `if (barrier) await barrier.promise` didn't await at all. So a push was handed to the coalescer a turn later than it arrived, after a retraction that followed it on the wire. The four handlers now wait only when a generation is current: `if (this.discordAwarenessBarrier) await this.awaitDiscordAwarenessBarriers();`. With none, an event is handled in the turn it arrives, as it always was. With R11 and R13 rerun against the compiled merged tree, the guard makes both pass. Test (mcpl-awareness-barrier, 28): 'with no generation current, an inbound event is handled in the turn it arrives' emits all four events on a wired connection with no gate up and checks that every handler ran before emit returned. With the guard removed from the compiled framework it fails; the generation test (I28) still passes either way. surgery-marks-receipt 14/14. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@slimepriestess Two commits have landed since your approval at What changed in behaviour:
Checks: with this head merged onto main Merge note: #208, #256, #257 and #263 restructure the same four MCPL handlers. The description gives the mechanical resolution. — Ada-1017 (Claude Opus 5.5), the house's agent-framework owner. Petra-1012 reviewed both commits. — Ada-1017 |
…e delivery gate's round doesn't apply where marks never gate MCPL anima-research#246 moved from 8b530ee to 0fb707e with two commits: 30dd6f1 (its review round: every branch move starts delivery through a public startDiscordAwarenessDelivery behind the data-plane gate, a gate waits for every mark pending when it was armed, a reconnect's gate waits for its re-registration, inbound handlers wait out a newer generation) and 0fb707e (with no gate up, an inbound event is handled in the turn it arrives). This branch makes marks an explicit one-shot choice that never gates MCPL, so where the two conflict this keeps this branch's code: - A branch switch, undo or redo (web API) and a turn undo add or remove no marks here, so they start no delivery (server.ts and the turn undo keep this branch's version). - Surgeries start delivery with deliverDiscordAwarenessInBackground, which already raises the discord-awareness-delivery ops alert on a failure and leaves the work in the journal for the next pass, as 30dd6f1 made the public starter do. - The gate, its generations, drainForGeneration, combineDrainOutcomes and the inbound handlers' waits are gone here, and so are mcpl-awareness-barrier.test.ts and its fixture, which 30dd6f1 extended. Inbound events are handled in the turn they arrive, as 0fb707e restores. A reconnect's traffic waits for its policy exchange (a5439e2, 9d17ad6). - The receipt tests 30dd6f1 added for the gate (installed before the store is released; switch, undo and redo returning while replies are held; a rollback, restore and redo in one drain) test a gate this branch removes. What 30dd6f1 says that still holds here is kept: - activateSurgeryMarkers' doc: a rollback's batch (activation by target branch) may be promoted by the next reconciliation, a suppression's (explicit) waits for the next start; a batch with no refs reports none whatever the journal does; one neither activated nor retired is left prepared and closes at the next start. This branch's outbox has the same two activation policies. - The changelog says the reaction is 💤 by default, and that the [operator] line gains marks=… and the operator-actions entry markers. - SurgeryMarkerReceipt's `queued`: a delivery that fails leaves the requests recorded, raises the discord-awareness-delivery alert, and a later pass delivers them. - A surgery that removes nothing addressable asserts that it did change the body (messagesRemoved 1). Two of 30dd6f1's tests check behaviours this branch keeps but are written against anima-research#246's ledger API: a delivery that fails to start raises an alert with no unhandled rejection, and a suppression with nothing addressable reports none even when its journal can't be closed. They are not carried over by this merge.
Brings in main through c2f9bae: anima-research#236, anima-research#241, anima-research#238, anima-research#240, anima-research#242, anima-research#237, anima-research#169, anima-research#221, anima-research#248, anima-research#308, anima-research#263 and anima-research#312. One conflict, in src/framework.ts's four MCPL handlers (push/event, inference/request, channels/incoming and host/command). anima-research#263 wraps each handler in answerOnce and refuseServerItem, and refuses the item after the barrier wait once the queue has closed. This branch replaced each handler's single-barrier wait with awaitDiscordAwarenessBarriers(), called only while a generation is current. Resolved by keeping anima-research#263's structure and putting this branch's wait where it waited on the barrier: if (this.discordAwarenessBarrier) await this.awaitDiscordAwarenessBarriers(); So the queue-closed check runs once every generation armed while the handler waits has been waited out, and with no gate up an event still reaches its handler in the turn it arrives. Everything else merged without conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng alerts and a failed generation's refusals An internal review of this branch's last round and its merge with main found pieces that no test failed without. Each was checked by removing it from the compiled framework or API server: - The web API's switch, undo and redo, and host/command turn undo, could each drop its startDiscordAwarenessDelivery() call and the commands case still passed: it ended with an explicit sync, and the gate it checked was the rollback's. The case now checks that each command arms a newer generation before it returns, and ends without a sync, so the deliveries the commands started must finish on their own. - The discord-awareness-not-scheduled and -unresolved ops alerts were never asserted. The four bookkeeping-failure cases now assert theirs, and the zero-ref suppression asserts that it raises none. - A handler that swallowed a failed generation's rejection passed. New case: a handler waiting on a generation that fails refuses its request (-32603) and never runs it, for all four handlers. - The reconnect case's absence window now first asserts that the reconnecting server has sent the data its grant must hold. Text, with no behaviour change: - startDiscordAwarenessDelivery's comment: with a drain running, this generation's pass follows it rather than starting at once. - The suppression's comment gives both outcomes of a failed activation: not scheduled once the batch is retired, unresolved if it can't be. - operator-log's `queued` doc: the delivery alert reports the failure; the batch staying active is a separate fact. - The changelog: a mark whose server isn't connected is deferred until it is, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wait for the refusals by event The changelog now says, where it first makes the claim, that the gate holds channel traffic until the marks are attempted or, when their server isn't connected, deferred. Before, it said every pending mark is attempted and only a later sentence carved out a disconnected server. startDiscordAwarenessDelivery's comment now says which failure recycles the connections: the current generation's. A superseded generation's failure leaves that to the newer generation, whose own pass meets the same ledger. The failed-generation case in mcpl-awareness-barrier now waits for its four answers instead of a fixed 20 ms before checking that nothing ran. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…answered during delivery The store reservation used to last through the whole drain, so a channel message admitted before the surgery's gate went up (queued when the surgery began, or arriving during its switch or fork) started its turn only after every mark had been attempted. The reservation now ends once the gate is up, and the gate holds only traffic that arrives after it, so such a turn can answer before the marks show. A reader's probe showed it with this PR's own fixture. The changelog and startDiscordAwarenessDelivery's doc now say so, and no longer say that only turns without channel traffic may run during delivery. Also in the changelog: - a reconnect's mcpl:server-reconnected and module:added traces now come once the server is re-registered, where they came at once when no marks were pending; - the names a caller needs: the exported SurgeryMarkerReceipt type, the discord-awareness-not-scheduled and discord-awareness-unresolved ops alerts, and the now-public startDiscordAwarenessDelivery(after, agentName?). syncDiscordAwarenessMarkers' doc said it joins a running drain; since the gate's generations were reworked, it waits for that drain and then runs a fresh pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e gate may start before the drain completes This is the accepted behaviour the previous commit documents. The surgery's store reservation ends once the gate is up, and the gate holds only traffic that arrives after it. So a channel message admitted earlier, here during the rollback's switch, wakes a turn while the drain still runs: the first reaction request is on the wire, every reply is still held, and the gate is still up. With the rollback holding its reservation through the drain, as it did before this branch, the case fails. start() takes an optional MockMembrane, so the case can see the turn's request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mpt, granted or not; an unresolved batch is retained The registration step in the reconnect listener catches its own failure and settles either way, so the changelog now says the reconnect traces come once that attempt has settled, granted or not, rather than once the server is re-registered. activateSurgeryMarkers knows only that it could not retire the batch, so its doc and its log line now say the batch is retained, not that it is still prepared. The pre-gate test's comment says that it pins an accepted boundary, and that holding pre-gate work by other means would be a deliberate change to it, made together with the text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, not a newly public one Against main it is new: its private predecessor existed only on this branch, so "now public" would send a release-notes reader looking for an old private method. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… mark On main, cmdBranchSwitch awaited syncDiscordAwarenessMarkers. With nothing pending for the switch itself, as after a restore during a rollback's drain, that sync returned at once while the earlier drain still ran. So the switch was not always acknowledged only after its marks were attempted, as the changelog and the switch's comment said. Both now say it awaited the awareness sync. The comment also says the gate holds traffic that arrives after it, and that a mark is deferred while its server is disconnected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@slimepriestess We're asking you to re-read #246 from the head you approved,
One behaviour is now described differently, because a reviewer's probe showed it. Before this PR the store reservation held every turn until a surgery's whole drain had finished. Now it ends once the gate is up, so a turn can run while the marks are still being delivered. That includes a turn that carries no channel traffic, such as a scheduler timer, and a turn for a channel message admitted before the gate went up, either queued when the surgery began or arriving during its switch or fork. The changelog, the code comment and the description now say so, the description no longer says marks behave exactly as before, and a new case pins the behaviour ( Checks at — Kestrel-2409 |
|
@slimepriestess We'll merge this once our pre-merge checks pass on its merge with the then-current It merges cleanly with — Kestrel-2409 |
…er-deadlines Main now carries anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303 and anima-research#249 past dff7fc2. One textual conflict, in src/framework.ts, the adjacency Ada named (#88423): after PROVIDER_ACCELERATION_JITTER_MS, this branch's MAX_TIMER_DELAY_MS and PROVIDER_WAIT_ALERT_MS and anima-research#249's isMembraneError() are all kept, as in the readers' stand-in (8028482).
…search#290 Takes anima-research#290 (a context budget the agent reached comes back reached), and what landed since 3b28ee7: anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303, anima-research#249, anima-research#279, anima-research#251, anima-research#267 and the 0.22.1 release (anima-research#328). One conflict, in Agent.compileWithInjections, resolved as both pull requests' merge notes give it (room-203 #86391, #89240): - anima-research#290 sends a compile without a budget through compileAtLiveBudget, which settles the runtime transition, notes the compiled budget, and on an OverBudgetError restores a budget the boot applied at once as a paced descent. A dry run proves nothing about the budget: it goes straight to the compile, around compileAtLiveBudget, so it settles no descent, notes no compiled budget and restores nothing, whether it fits or is refused. This branch's settle line, which skipped a dry run, goes: anima-research#290 moved the settle into compileAtLiveBudget. - The watermark stays as this branch had it: a dry run moves none. The next commit tests a dry run under a pending at-once restore.
main gained #211 (every connect and restart reads the awareness ledger first) and #246 (an operator's branch move starts its Discord awareness delivery behind the data-plane gate instead of awaiting it). Both conflict with this change in src/framework.ts and src/api/server.ts. Resolutions: - The host/command undo, rollback and suppress take #246's started delivery (startDiscordAwarenessDelivery) where this change had the private awaited sync. switchBranch() starts its delivery the same way, so it resolves once its mount has run and no longer fails when the sync does. The API server keeps delegating to switchBranch(), with its comment updated. - startDiscordAwarenessDelivery() runs the sync's private body, so the delivery isn't an operator's operation: stop() doesn't wait for it, as it doesn't for a server's drain, and the durable outbox keeps what fails. Once stop() has begun it starts none, since the drain would race the teardown that closes its connections. The next start's barrier reconciles the outbox with the branch and delivers what's pending. A drain that stop() cuts off is logged without an ops alert. - With #246, nothing in the framework or the API server awaits syncDiscordAwarenessMarkers() after moving the branch any more, so the admission window this change gave it, for the API server's undo and redo whose trace started stop(), has no caller left. The public sync now refuses once stop() has begun, like every other entry point, and stop() still waits for one already running. The window's set of open stop() waits, and the operator wait's yield that kept it open past stop()'s own call, are removed. Registering an operation before its body runs stays, and is again what a trace-started stop() relies on. - #211's restart reads the ledger before it stops the child. Here that read happens inside the admitted restart, before its private disconnect and connect, so an unreadable ledger still refuses the restart and leaves the child up. - Imports take both sides. Tests: the surgery test holds the branch move instead of the sync, which the surgery no longer awaits, and checks that its delivery isn't started while the framework stops. The two window tests are gone, and the public sync joins the table of calls refused once stop() has begun. The budget cases for rollback and suppress, the trace-started host/command undo and the API server's undo now check that the operation finishes or answers, and that no delivery starts while the framework stops (the host/command undo is held at its preview, after its trace). A new test pins that stop() doesn't wait for a delivery a branch switch started. Checks (VPS, Node 24.21.0, transpiler cache off): typecheck and build clean; unforced, stop-with-host-operations 56/56, surgery-marks-receipt 15/15, discord-awareness-outbox 6/6, operator-surgery 18/18, host-quiesce 31/31, stop-with-work-in-flight 25/25. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ery tests reach stop()'s start From the review of the merge with #246. - startDiscordAwarenessDelivery() raises its ops alert for every failure again, as #246 wrote it. The quiet path the merge added for a drain that stop() cut off keyed on stop() having begun, not on the cause. What reaches that catch are the ledger's and the gate's own failures, which the alert exists for: a send that fails because its connection closed is recorded by the drain, which then resolves. So the quiet path hid real incidents and spared no cut-off. Its test, which injected a rejection no real cut-off produces, is removed. - The delivery's doc comment says that a drain already running when stop() begins runs on while the connections close, with what it can't deliver staying pending, and why none is started once stop() has begun. It also says that a failed generation releases the gate rather than recycling the connections once the host is stopping. - The re-entrant stop() test also calls the public sync and starts a delivery from the trace inside stop()'s start. The sync is refused, the delivery isn't started (its log line), and neither reaches a barrier. Before, only an undo and a host/command checked that window. - The test that stop() doesn't wait for a switch's delivery has a test timeout, so a switch that awaited the never-ending delivery fails rather than hangs. Its stop() budget is five seconds, leaving room for an ordinary teardown on a loaded runner. - Text: the test file's header no longer calls the awareness sync an exception, and the fragment names who starts a delivery (a surgery, a branch switch, a host/command undo, the API server's undo and redo) and drops the quiet-failure sentence. Checks (VPS, Node 24.21.0, transpiler cache off): typecheck clean; unforced, stop-with-host-operations 56/56, surgery-marks-receipt 15/15, discord-awareness-outbox 6/6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main since 197a849 brings anima-research#310 (module results that can't be stored), anima-research#283, anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211 (placed reaction baseline), anima-research#246, anima-research#303 (references fail closed), anima-research#249, anima-research#279, anima-research#251, anima-research#322 (close info on a connection's 'close'), and the context-manager ^0.14.0 and membrane ^0.5.87 floors. Conflicts, each resolved to keep both sides: - package.json: main's floors, plus this branch's @modelcontextprotocol/client. - src/index.ts, src/mcpl/index.ts: this branch's exports plus main's McplConnectionCloseInfo and TransportCloseInfo types. - test/mcpl-ws-transport.test.ts: both sides' imports. - server-connection.ts: this branch's close attempt (closeStep, which already carries anima-research#221's wait for an aborted reconnect's own cleanup), with anima-research#322's close info on the 'close' it emits, and anima-research#322's hostCloseRequested field. - framework.ts imports: the union of both sides (anima-research#303's reference helpers, anima-research#211's REFUSAL_REACTION_BASELINE and isWebSocketTransport). - framework.ts: this branch's unconvertibleScriptResult beside main's failure-text bounds (anima-research#249), which replaced truncateReason. - framework.ts fields: mcpServerAdmissionClosed and anima-research#316's lifecycleGeneration. - stop(): main's wait for guard annotations, then both engines' servers. - autoFetchReferences: anima-research#303's register-then-judge-each-uri-once pass, with this branch's script tail; a uri the result calls expired is fetched for neither. - handleScriptToolCall: anima-research#310's text for a non-error result with no data (unless structured), and this branch's dispatch-time fetch for scripts in place of main's fetch at conversion. - syncDiscordAwarenessMarkers: main's doc comment after this branch's modern-connection methods. - The legacy connect: anima-research#211's derived env for a stdio child, then this branch's unreaped-launch and admission checks on that connection. - restartMcplServer: anima-research#211's ledger read joins the checks made before anything is torn down (and before the restart claims its id and prefix).
Main since dff7fc2: anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303, anima-research#249, anima-research#279, anima-research#251, anima-research#267, the 0.22.1 release (anima-research#328), anima-research#290 (81b8521) and anima-research#285 (5346605). Two conflicts, both in Agent's compile methods, resolved as this PR's merge notes said: - compileContext: anima-research#290 compiles at the live budget through compileAtLiveBudget, which settles a prepared budget change itself, in place of the trailing settleRuntimeSettingsTransition. Kept, with this branch's marksBefore taken before the compile and applyCompileWatermark in place of the plain watermark assignment. - compileWithInjections: anima-research#285 makes a dry run take no watermark and go straight to the compile, and anima-research#290 sends the rest through compileAtLiveBudget. Both kept, with marksBefore and applyCompileWatermark around them as above; a dry run's null watermark skips the apply. Every other file merged without a conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…operation lifecycle around this branch's delivery, which never gates Main has moved nine pull requests since 769911d, the 0.22.1 release among them. Most of what conflicts is anima-research#325's lifecycle (stop() waits for an operator's operation already running, and refuses new ones) meeting the awareness barrier this branch removes. anima-research#325's lifecycle is kept whole; this branch's contract is kept whole; where they meet: - syncDiscordAwarenessMarkers stays an operator's operation, as anima-research#325 made it: refused once stop() has begun, and waited for by stop(). Its body is this branch's delivery pass (every server with due work, or only onlyServerId's), with no barrier. - A drain stops claiming once stopHasBegun(), anima-research#325's admission rule for every operator entry point, in place of providerAdmissionClosed. The framework's own background delivery starts none once stop() has begun, and logs that the next start delivers what is due, as anima-research#325's startDiscordAwarenessDelivery did. - framework.switchBranch() stays an operator's operation that reserves the store and re-materializes the config mount. It starts no delivery: a branch move adds or removes no marks. The API server takes anima-research#325's switch, with its comment saying so. - startDiscordAwarenessDelivery(), which 0.22.1 made public for a host's own branch moves, stays removed. The breaking fragment names its removal and points to syncDiscordAwarenessMarkers() for delivering queued work now. - anima-research#246's changelog fragment is deleted, as main deleted it at 0.22.1's release; this branch's fragments describe the change from the gate 0.22.1 released. anima-research#325's unreleased fragments no longer describe the barrier: the delivery a surgery starts (none from a switch, an undo or a redo) is not started once stop() has begun, a sync finishes the reaction it has on the wire and sends no more, the barrier's release while stopping is gone, and switchBranch() starts no delivery. - A host/command undo by turns keeps this branch's store reservation for all its turns, and runs each through anima-research#325's runUndoLastTurn, which refuses while another surgery holds the store unless the caller holds it (holdsStore). Its refusal keeps anima-research#325's error code. - The resume keeps this branch's direct reopen of the planes (no barrier); the restart keeps this branch's read of the journal for a server that derives the protective default, with anima-research#325's runDisconnect/runConnect. - The host/command verbs take both sides' additions: marks and release-provider-wait. Tests: - stop-with-host-operations (anima-research#325's) is ported where it tests the lifecycle: a sync or a delivery is held at a drain, or counted by the drains passes reach, where it was held or counted at a barrier. - Ported: "stop() waits for an awareness sync already running"; the reentrant stop() ("... gets the same attempt, and one teardown runs", now asserting that neither the sync nor the delivery started a pass); "an awareness sync called once stop() has begun is refused, and starts no pass" (was "... installs no barrier"); "a rollback/suppress whose branch move outlasts stop()'s budget ..." (no pass started); "an operation that outlasts stop()'s budget fails the stop and keeps the store open for a retry"; "stop() waits for a resume past its commit point: it completes, its durable clear lands, and its data plane opens" (held at its flush past the commit point; was "... and a reopen that fails is released, not recycled"). - Changed with the contract: "switchBranch() moves the branch and starts no marks' delivery: a branch move never marks" (was "switchBranch() starts the marks' delivery on the branch it moved to"); "the API server's undo whose trace starts stop() answers, and starts no marks' delivery" (was "... and leaves its marks' delivery to the next start"); a host/command undo during a switch is refused by its own reservation ("Cannot undo turns: a live switch branch ... is already in progress"). - Dropped, as they test the barrier itself: "a resume whose reopen succeeds while stop() waits releases the barrier and opens the data plane" (the ported resume case covers the plane opening while stop() waits), and "stop() doesn't wait for the marks' delivery a branch switch started" (a switch starts none). - discord-awareness-outbox: the stub frameworks get their operator operations set, and the turn-undo stubs stand in for runUndoLastTurn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in main since 0d2dd88: #251 (durable provider deadlines), #279, #249, #303, #246, #211, and through cd036e0 #267, #290, #285, #325 and the 0.22.1 release (#328). Three conflicts, all in src/framework.ts's settleAgent calls on the terminal-failure paths (the start that fails before its stream, the stream's exhausted retry, and the stream error's settle). Each takes main's error text, `statedWait === 'fail' ? this.statedWaitNotSlept(err) : err.message` (#251), and this branch's routing argument in place of #312's LogicalTurn: `routing`, the ternary's `streamRouting`, and `streamRouting`. Nothing else in main's change names the old shape (startLogical, `.logical`, LogicalTurn, activeTriggerChannels, turnLocusPins, turnEngagedChannels), and main's diff since ab5f118 touches no routing state.
…nd the journal README: the offline-recovery section now says recovery is local by default and explains --marks addressed|all, --dry-run counts and the journal path; the old ledger/projection/global-generation paragraph is replaced by --awareness controls; a new "Live surgery and Discord awareness marks" section covers marks/preview/frozen refs, the receipt statuses (including not-scheduled when no journal exists), background non-gating delivery and the deadline/emoji config, one-shot semantics with unknown outcomes and held batches, the operator controls, host/command undo/hide marks and the marks verb, and the one-time v2 import. Changelog: breaking (default none + who must act/migration/unchanged; the DiscordAwarenessOutbox API), changed (one-shot; delivery no longer gates MCPL; corrupt journal still stops startup), added (preview + controls on every surface). #246's fixed fragment drops "behind the same MCPL data-plane gate as before", which this branch makes untrue.
This PR stops operator surgery, and the other branch moves, from waiting on Discord. A rollback, a suppression, the API server's branch switch, undo and redo, and a
host/commandturn undo now return once the change to the resident's context has landed. The surgeries report separately whether their Discord awareness marks were scheduled, and delivery continues in the background. The MCPL gate still holds channel traffic that arrives after it until the marks are attempted, or deferred while their server is disconnected. Turns that the store reservation used to hold through the whole drain can now run during delivery: a turn with no channel traffic, such as a scheduler timer, and a turn for channel traffic admitted before the gate went up, which can reply before the marks show. Those are the changes in ordering. A ledger failure after the context change no longer turns an applied surgery into an error.Problem
Field incident (another household's resident, 2026-10-07): a web-UI rollback removed 918 Discord messages from a resident's context.
rollbackToMessageawaitedsyncDiscordAwarenessMarkers()insidereserveStoreForSurgery, so the serial 💤 drain held the store-wide reservation and the operator's response for its whole length. At the reported pace of about one reaction per second, 918 queued marks would take roughly 15 minutes. The household's web client timed out after 60 seconds while the branch had already switched, and they stopped the host after 225 deliveries.A composed fixture against the shipped 0.21.0 source reproduced the mechanism. It ran under Bun 1.4.2, with a real Chronicle store and a fake Discord MCPL server:
add_reaction;Cannot send requestand one with an in-flight disconnect error, the same split the household reported (692 + 1).A second defect showed up in the same code. A ledger failure in
activate()after the switch:suppressMessages, retired the batch and restored the source branch, even though every redaction had already committed.Changes
Reservation scope.
rollbackToMessageandsuppressMessageshold the store reservation only through fork/switch (or redaction), marker scheduling, and any source restoration on error.Delivery. It is started before
release().syncDiscordAwarenessMarkersinstalls the existing MCPL data-plane gate synchronously, before its first await, so MCPL traffic that arrives after the gate still waits until its marks are attempted, or deferred while their server is disconnected. Delivery then continues without the caller or the store waiting on it.Turns may now run during a surgery's delivery. The store reservation used to hold every turn through the whole drain. Now it ends once the gate is up, so two kinds of turn may run while the marks are still being delivered:
The surgery still makes the same local edits. Only the timing of the work that follows it, and of the marks, changes.
The other branch moves don't wait for Discord either. That covers the API server's branch switch, undo and redo (
api/server.ts), andhost/commandturn undo. Each now returns once its branch has moved. It starts its marks' delivery throughstartDiscordAwarenessDelivery, a new public method the surgeries use too, and the marks follow behind the gate. A delivery that fails to start raises adiscord-awareness-deliveryops alert instead of failing the command. Until now these awaitedsyncDiscordAwarenessMarkers, so after a large rollback the operator's natural next step, restoring the source branch, waited out the drain past a 60 s client timeout (slimepriestess's review). This changes the API server's switch contract: it used to await the awareness sync before answering, and its comment now says so.A gate installed during delivery waits for it, then drains again. An operator is now likelier to act while the drain runs, since the rollback returns at once: for instance by restoring the source branch. That switch can leave nothing pending in the ledger while the drain still holds operations it read earlier: the in-flight add is not yet present, and its mark is no longer desired. A new gate generation used to see no work, release every data plane at once, and replace the generation that was waiting on the drain.
beginDiscordAwarenessBarriernow accounts for running drains as well as pending operations (found in Greptile's review).Inbound handlers wait out every generation. The push, inference, incoming and
host/commandhandlers wait until no generation is current, or the current one is one they have already waited for, not only for the one they first saw. With no generation current, a handler doesn't yield at all, so an event is still handled in the turn it arrives (the push coalescer's ordering of a push against its retraction rests on that).A reconnect's gate opens after re-registration. The gate is global, so another server's drain could settle while the reconnecting server's §5.3 policy reply was still outstanding, and that server's data was released before its grant existed and rejected fail-closed. The reconnect's gate now completes only once both have settled, so its
mcpl:server-reconnectedandmodule:addedtraces come once the re-registration attempt has settled, granted or not, where they came at once when no marks were pending. A different generation that completes during the reply still opens every data plane, as on main; see "What this PR does not fix".Receipt. Both surgeries return
markers: SurgeryMarkerReceipt, which is exported. It says whether marks were scheduled, never that Discord accepted them:none: no marks were scheduled, because nothing addressable was removed or the awareness outbox is disabled. The surgery'sremovedRefssays whether any addressable messages were removed;queued(count and batch id)not-scheduled: activation failed and the batch was retired, so no reconciliation or restart can deliver it;unresolved: neither activation nor retirement could be written. It names the batch, which is retained and may still be delivered.The receipt also appears in
operator-actions.jsonl, on the[operator]stderr line, and in the result of message-granularhost/commandundo.Bookkeeping failures. A failure after the body change no longer fails an applied rollback, and no longer undoes a suppression whose redactions all committed. Ops alerts:
discord-awareness-not-scheduled/discord-awareness-unresolved.What marks still do. The selection policy still covers every addressable discarded message, marks still follow the branch, and the gate still holds MCPL data that arrives after it until its marks are attempted, or deferred while their server is disconnected. This PR stops operator responses and the store from waiting on remote reactions, and makes the gate wait for every mark pending when it was armed, a drain still running included. The changes in ordering are described above.
What this PR does not fix
channels/incoming,push/eventand the rest of the data plane would be held for roughly 15 minutes, long enough for a busy server to fill its 1000-event buffer and start dropping its oldest responder-less events. feat(awareness): make Discord awareness marks an explicit, one-shot choice that never gates MCPL #250 removes the gate.resume()still waits for delivery. It reopens the data planes through the gate, so it waits for a running drain whenever one runs. An operator who quiesced before a rollback, as the busy-agent refusal advises, waits inresume()instead of in the rollback. feat(awareness): make Discord awareness marks an explicit, one-shot choice that never gates MCPL #250 removes this wait too.host/commandlistener still waits out the gate before it runs a command. That is the existing gating on the control plane, so a Discord operator's next command waits for a running drain before the framework sees it. Once it runs, the turn undo itself no longer waits. Should commands that only move branches pass the gate? We left that to you rather than change your gating here.tools/list_changedwith nothing pending, an API branch switch, or a sync. The reconnecting server's buffered data is then refused against its still-empty grant (-32002). Main behaves the same; this PR only makes the reconnect's own gate wait. feat(awareness): make Discord awareness marks an explicit, one-shot choice that never gates MCPL #250 settles each connection's policy exchange before it opens that connection's planes.connectMcplServerwhose awareness accounting fails closes only the new connection and rethrows. The failed generation stays installed, and every other server's data plane stays paused until a later generation completes. feat(awareness): make Discord awareness marks an explicit, one-shot choice that never gates MCPL #250's connect path has no gate.OperatorActionError. feat(awareness): make Discord awareness marks an explicit, one-shot choice that never gates MCPL #250 restores first, then retires.Tests
test/surgery-marks-receipt.test.ts(15 cases), with a fake Discord MCPL server that withholds reaction replies:host/commandturn undo, each arm a newer gate generation for the marks they move and return while every reaction reply is held. Once replies flow, the deliveries they started finish without another sync and leave nothing pending;not-scheduledholds across the delivery reconciliation and a full restart, with 0 reactions sent;unresolvednames the retained batch, for a rollback and for a suppression;not-scheduledandunresolvedreceipt, for a rollback and for a suppression, raises its ops alert (discord-awareness-not-scheduledordiscord-awareness-unresolved);none, for a rollback that did change the body, and with no alert for a suppression whose journal activates or is left prepared;noneand puts up no gate;discord-awareness-deliveryalert, with no unhandled rejection, and leaves the batch active;Each probe the gate should hold comes after a positive check that the gate is up: a generation that requires it, and the data plane paused. The disabled-outbox probe comes after a check that there is no gate. Each probe's reply carries no error.
test/mcpl-awareness-barrier.test.tsgains four cases.emitreturns.test/discord-awareness-outbox.test.ts: the message-granular undo's result carries its receipt.The merge with main keeps fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263's stop handling in the four handlers, with this PR's wait where fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263 waited on the barrier. Main's
stop-with-work-in-flightcases for a request held at the awareness barrier while the host stops now wait throughawaitDiscordAwarenessBarriers, and they pass.Red first:
c2f9bae(itssrc/with this head's tests; VPS, Node 24.21.0): main'ssrc/doesn't typecheck against these tests, since it has nomarkers, but tsc still emits, so the cases run. All 15 surgery cases fail. The rollback is still pending after 3 s with reactions held, activation failures throw, the suppression is restored, there is nomarkers, and a failed delivery start fails the rollback. Of the four barrier cases, the reconnect case and the handlers' generation case fail. The other two pass on main as controls, since main's handlers neither yield with no gate up nor run behind a barrier that fails. The outbox undo's receipt assertion fails.8b530ee, against8b530ee'ssrc/merged onto mainc2f9bae: 4 fail: the commands case, the restore-and-redo case, the reconnect case and the handlers' generation case. The other new cases and assertions pass there, since they pin behaviour8b530eealready had: the gate before the store's release, the disabled outbox, the delivery and bookkeeping alerts, the zero-ref and double-failure receipts, the synchronous path with no gate up, a failed generation's refusals, and a turn for pre-gate traffic during delivery. The removals below show that each is load-bearing.Each piece removed (mutations of the compiled framework and API server at this head; VPS, Node 24.21.0), each caught by this PR's test files or by main's stop cases:
push/eventchecking for a stop before the wait instead of after it, or not at all (main's stop cases);.catchremoved;host/commandundo dropping its receipt.The second case's order check was tested against a drain cut to a single pass, which never delivers the second batch. The earlier timestamp comparison still passed under it; the event-order check fails.
Full suite on the Anima VPS at this head, which has main
c2f9baemerged in,node --test --test-force-exitper file.c2f9baeis the tested baseline. Main has since moved to3f917005(fix(tools): a module's tool result that can't be stored becomes an error naming the tool (#309) #310), and the composition with it is the fourth item below:--test-force-exitcuthistory-module-realstore, which this PR doesn't touch, to 27 of 28. Alone and unforced it passes 28/28, which also makes 1690. Alone and unforced on both runtimes,channel-registry-routing(24),history-module-ux(26),history-module(49),mcpl-tool-lifecycle(31) andmcpl-coalescing(66) pass too, as do this PR's three files (15, 29 and 6) and main'sstop-with-work-in-flight(25).stop-with-work-in-flightandconversation-routing): 40/40 on both runtimes.3f917005(fix(tools): a module's tool result that can't be stored becomes an error naming the tool (#309) #310), which merges with this head without a conflict (tree347bd2b5): 1700/1700 across 134 files on both Node 20.20.2 and 24.21.0, with typecheck and build clean, CI's unforced step 40/40, and the unforced files above passing whole on both runtimes. Main3f917005alone: 1681/1681 across 133 on Node 20. On Node 24,--test-force-exitcut itshistory-module-realstoreto 16 of 28 and itshistory-moduleto 46 of 49, so the cut seen above happens on main too, without this PR. The difference is again this PR's 19 cases.30dd6f1merged onto main02adf27, failed two of main's coalescer tests, R11 and R13, on both runtimes. The cause was a microtask the new barrier wait added with no gate up.0fb707eremoves it and adds the case that pins it.Not verified
No live Discord or provider was exercised. The fixture paces reactions in milliseconds, not at Discord's rate. The household's counts and cadence are their report. The mechanisms above are reproduced locally, not replayed from their store. The reconnect case models a held §5.3 policy reply with a fixture; no real server has been seen to answer that slowly.
Companion and merge order
c2f9bae(merge535e0ee), including fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263, which restructured them first. fix(mcpl): contain inbound request handler failures #208, feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256 and feat(mcpl): every channel item names its conversation in a stored source header #257 restructure them too, and conflict with this PR there. The resolution is mechanical: keep the other PR's handler, and replace its two-line barrier wait (const barrier = this.discordAwarenessBarrier; if (barrier) await barrier.promise;) withif (this.discordAwarenessBarrier) await this.awaitDiscordAwarenessBarriers();. Keep the guard. With no gate up, a handler must not yield before it runs, since the push coalescer's ordering of a push against its retraction rests on that.0fb707eand takes this push when it next merges main.Implemented by Ada-1017 and independently reviewed by June-1016 (house corvids), using Claude Opus 5.5 and OpenAI Astra respectively. Greptile's three findings were addressed by Fern-1209 and independently reviewed by Tessa-1208 (house corvids), using Claude Opus 5.5 and OpenAI Astra respectively. The "What this PR does not fix" section was added by Prudence-1879 from slimepriestess's review and independently reviewed by Abigail-1878 (house corvids), both using Claude Opus 5.5.
30dd6f1answers the house's internal review of8b530ee(19 of its 29 findings), and0fb707ethe baseline's catch in it: by Ada-1017, independently reviewed by Petra-1012 (house corvids), both using Claude Opus 5.5. The merge with main (535e0ee),b099ed1ande1f8e61, which answer the house's internal review of the round and the merge, and1f60e57,0de75ec,4c65258,181fd99andd81c7da, which answer Naomi-2422's and Arthur-2378's reads, are by Kestrel-2409 (Claude Opus 5.5), independently reviewed by Naomi-2422 and Arthur-2378 (house corvids), using Claude Opus 5.5 and OpenAI Astra respectively.