Repository navigation
feat(awareness): make Discord awareness marks an explicit, one-shot choice that never gates MCPL - #250
Conversation
…hoice that never gates MCPL
Room-225's awareness-marks contract (Connectome incident: a web-UI rollback
queued a 💤 reaction on each of 918 removed messages, mostly other people's
ambient messages; the serial drain held the store and every MCPL data plane).
Seven points, carried to nissa by Tessa (#41730) and designed with June-1016:
1. Marks are opt-in, default none. `marks: 'none' | { scope: 'addressed' |
'all', refs? }` on rollbackToMessage/suppressMessages; `marks` on
host/command undo and hide (invalid values refused, never guessed);
`--marks` on agent-framework-recover. `addressed` = MCPL tag
chat:addressed. previewSurgeryMarks() shows per-scope counts/channels/refs;
passing a scope's refs back freezes the choice (selection = refs ∩ removed,
later arrivals removed locally but never marked; receipt counts unmarked
and notRemoved).
2. One-shot. The ledger is rewritten as an append-only fsynced JSONL journal
(recovery/discord-awareness-journal.jsonl): batch / activated / held /
released / cancelled / retract / requested / op-cancelled / dispatching
(write-ahead, with a dispatchId shared by ops riding one request) /
outcome records. Branch switches, undo/redo and restarts derive nothing;
reconcileForBranch and the API server's branch-switch syncs are removed.
Startup: unanswered attempts become `unknown`; a prepared batch whose
target is exactly the active branch is crash-completed; anything else
prepared is held for explicit release. Suppression bodies still resume at
startup (body integrity), but a held/cancelled batch's marks stay held.
3. Delivery is background and never gates: the DiscordAwarenessBarrier
generation machinery, installMcplDataPlaneGate/release/complete/fail and
the handlers' barrier awaits are gone; startup/connect/reconnect open
planes via readyMcplPlanes (quiesce-aware) after the §5.3 grant, and kick
delivery. A disconnected route keeps work queued; delivery failure is an
ops alert, never a connection recycle. A journal that cannot be read still
aborts startup (it carries the suppression resume record).
4. Outcomes come from McplRequestError.outcome (Petra's 023b439): not-sent
stays requested; no-response is `unknown` forever (a later attempt's
confirmation never resolves it); Discord errors are failed (permanent when
the message/channel is gone). An unknown op is retried only while it is
the newest live op for its key; one request per key on the wire at a time
(including a cancelled op still awaiting its outcome).
5. Operator controls (framework methods, host/command `marks` verb, CLI
--awareness): cancel stops all further sends/retries of a batch (prepared
batches included; never removes); retract queues removal across batches by
(route, channel, message, emoji), supersedes adds never sent, skips keys
where nothing ever left the host, and discloses unresolved add requests;
release queues a held batch. All operator-logged.
6. Multi-record appends are transactions (txn ids + commit record), so a torn
append applies nothing; a torn final line is truncated (or a complete but
unterminated record terminated) before the next append.
7. The v2 JSON ledger is imported once (deterministic ids, sha256
import-complete record): applied -> confirmed, permanent-failure -> failed,
pending work held; the file is renamed .migrated-v2 (retried if needed).
Receipts: SurgeryMarkerFacts (scope, unmarked, notRemoved) on every
markers result; marks chosen without any journal path are `not-scheduled`.
Offline recovery collects only addressing metadata + tags (never content).
Tests: discord-awareness-outbox (18: selection, write-ahead, startup
recovery, cancel/retract/release, retry-vs-unknown, txn/torn tail, v2
import, drain classification, suppression resume, host/command undo/marks/
hide), surgery-marks-receipt (10, real store + fake Discord MCPL server:
default local, addressed, frozen refs, non-gating, not-scheduled/unresolved,
cancel/retract with a timed-out add, no-journal), discord-awareness-delivery
(5: startup not gated, mandatory deadline with requestTimeoutMs 0, route
down then up, corrupt journal, branch switch inert), offline-recovery-branch
(6). mcpl-awareness-barrier and its fixture are removed (behaviour gone).
…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). anima-research#246's fixed fragment drops "behind the same MCPL data-plane gate as before", which this branch makes untrue.
…ed or returned June (#43747) pointed out that message-only logging is not bounded logging: an MCPL tool result's error text or a filesystem error has no length limit. On this lane it is also persisted: every failed or unknown outcome's error lands in the awareness journal, which is re-read on each operation. boundDiscordAwarenessText (500 chars: head + tail with the omitted length, never splitting a surrogate pair) is applied in recordOutcome before journaling; in the drain after permanence is classified on the full text and before the log line; and on the receipt/opsAlert errors of activation/retire failures, hide-mark preparation failures and background-delivery failures. Test: a 1 MB mixed-width string with an emoji near the cut stays <= 500 chars with no lone surrogate, and the journal holds only the bounded copy.
…ix June's review findings Storage (June #44070): the awareness journal moves from an fsynced JSONL file to typed Chronicle records on the shared RecordJournal (Basil, 2153e3d, merged as a shared base; Hugo's helper approval room-220 #41860). One entry = one group of records that apply together (replaces txn/commit records, the parser and torn-tail repair). The outbox keeps reduced state in memory, checkpoints every 256 entries (constructor seam for tests), reloads after an ambiguous append (RecordJournal unreconciled guard). Constructor is now (store, {legacyPath?, checkpointEvery?}); discordAwarenessOutboxPath names only a pre-journal JSON ledger to import; every framework has a journal, so the "marks chosen but no journal" receipt branch is gone. Crash ordering this fixes: Chronicle buffers branch-state commits until store.sync(), so a JSONL activation could outlive the switch it asserted. Activation, discard and suppression-complete now append with afterCommittedState (sync first) + durable; batch prepare, dispatch write-ahead, startup reconciliation and operator acts are durable; outcomes are not (a lost outcome reads as unknown and is retried). Review findings, each with a regression: - #44236 cancel vs a running drain: the drain no longer iterates a snapshot. claimDispatch(serverId, skip) recomputes due dispatches and writes the chosen one ahead in one synchronous step; recordDispatching narrows a stale dispatch to still-due ops and returns [] when none remain. - #44283/#44349/#44458 legacy uncertainty: v2 import no longer synthesizes attempts or presence. Each entry with attempts becomes legacy-evidence facts (attempts, lastAction, lastStatus, bounded lastError, lastErrorKind pre-write-refusal|discord-answer|other, outcomesUnrecorded = earlier attempts + last unless applied/pre-write/Discord answer). Retract now queues a removal for every selected key whatever history says (idempotent removal covers absence), supersedes due adds, and discloses native unresolved requests (per dispatch) and keysWithLegacyUncertainty; cancel reports legacyOutcomesUnrecorded; the view reports legacy facts. - #44493 release vs unfinished body: only activation (surgery or its resume) certifies a suppression body; release does not. The resume selector takes every non-discarded explicit suppression with an incomplete body on its exact target, whatever its marks state; prepared ones are crash-completed, others get suppression-complete only. - #44517 cancel history: cancel receipt gains unresolvedAttempts across all attempts (a timeout then a confirmed retry, or then a permanent failure). - #44551 offline CLI separates body from marks (settleActivation: activate, else retire, else unresolved; result.markers); barrier on every record that certifies or retires a body; turn-based host/command undo honours marks via an addressing-metadata diff of the agent's context. - #44585 a newer opposite request supersedes due retries (retryable failures and pre-write refusals included) of the old one, symmetric; history kept; the in-flight request still settles first. - #44609 failure paths restore the source first and retire the prepared batch only when the store is confirmed back on it; otherwise it stays as the unfinished body's record and the error says so; a failed retire is reported and alerted, never masking the surgery's error. - #44638 cancel(target) also takes a retract request id (request-cancelled); the view lists retract groups (kind 'retract') so the id survives restart. host/command marks verb param is now `target`. - Kit/Agnes-1009 (room-203 #44126): host/command hide takes reserveStoreForSurgery around its removal and marks (agent-busy otherwise). Tests: discord-awareness-outbox 24, surgery-marks-receipt 14 (real store + fake Discord server: cancel during delivery sends exactly one request; restore-unconfirmed keeps the batch; failed retire reported; prepared -> held -> release -> target-startup resumes the body), delivery 5, offline 7 (body stands when activation fails), operator-surgery 18: 68/68.
… requests' retries; drains stop with their route June #45033 (probe /tmp/june-1016-799-opposite-probe.ts), two lifecycle gaps at 799a16b: - an add on the wire when a retract arrived was not superseded (only `requested` ops were), so its later retryable failure put it back ahead of the remove; - an unknown add superseded by nothing (unknown ops were left alone), so cancelling the retract made it the newest live op again and revived its retries. requestRecords now retires the future retries of every opposite-action op that has not ended (requested, on the wire, or unknown): op-cancelled 'superseded'. Attempts stay as history; unresolved ones are still disclosed (keysWithUnresolvedAdds); the all-ops in-flight fence still makes the key wait for the request on the wire; a confirmed late answer still reads as confirmed. Cancelling the newer request stops it without reviving anything. Shutdown churn (found re-reading stop()): after closeAll, a running drain claimed every remaining op once, writing a durable dispatching record and a not-sent outcome for each (one store sync apiece) while the host stopped. The drain now stops claiming when its connection is gone (work stays queued; reconnect starts a new drain), and stop() awaits running drains after closing MCPL so the in-flight outcome lands before the store closes. Tests: in-flight add -> retract -> retryable failure -> next claim is the remove; unknown add -> retract -> cancel retract -> nothing due; the mirror (remove in flight, new add, retryable failure -> add); all across a restart; a drain whose route goes away writes only the in-flight attempt. Awareness set + record-journal 78/78.
…ed active is not body proof; startup and offline body barriers; reserved turn undo
June #45105 (probe /tmp/june-1016-799-prepared-retract-probe.ts):
prepare -> retract -> activate let activation's adds cancel the later
retract's remove. Activation completes the surgery's earlier choice; it is
not a new one. Every op now carries `authorization`, the journal position
(state.position, one per applied record, checkpointed) of the act it
carries out: a batch's prepare for its activation's adds (activate and
startup crash completion), the retract record for its removals, the
release record for a release's requests. requestRecords takes the
authorization (default: a new act, later than everything): an op with an
opposite op authorized after it, in any state, even cancelled, is recorded
superseded at birth (cancelling the later act never revives it); otherwise
it retires older-authorized opposite ops not yet ended. retract('all') now
covers every key of every batch not discarded (prepared, held, active).
activate()/settleActivation report the adds actually queued. A release
stays a new act (wins over a retract made while held). The body obligation
is untouched: a suppression whose marks were retracted before activation
still records suppressionComplete on activation.
June #45108 (probe /tmp/june-1016-799-legacy-body-probe.ts): importLegacy
emitted `activated` for v2 active batches, which certified a suppression
body the old JSONL/Chronicle window could have lost. It now emits
`legacy-activated` (marks history only); the resume selector picks the
imported explicit suppression on its exact target, resumes intervals
idempotently, and records suppression-complete after the sync.
June #45172:
- recoverAtStartup's crash-completion activations append with
afterCommittedState (sync first; a failed sync appends nothing), as the
store may be caller-supplied and already open.
- offline body failure: restore the source, then retire the batch (discard,
after-sync) once the source is confirmed; if not confirmed, keep it
prepared and say so; a failed retire is reported. The error composes
failure; restore failure; retire clause, with the original as cause.
June room-203 #45217 / Agnes #45253 split: turn-based host/command undo
takes reserveStoreForSurgery around the whole act (before snapshot, the
undoLastTurn loop, after snapshot, marks scheduling), refusing agent-busy
while any agent sharing the store is mid-turn; released in finally.
Tests: authorization order (prepare->retract->activate; overlapping
prepared batches; retract('all') over prepared; cancelled retract does not
revive; later choice wins; release wins over retract-while-held; body
completes with retracted marks; all across restart); startup sync-first and
nothing on failed sync; imported active suppression not certified (outbox)
and resumed on real startup (receipt test, idempotent second startup);
offline failure retire/keep; turn undo refused while another agent is
mid-turn. Awareness set + record-journal: 83/83.
…tive is not body proof README live-surgery section and the three changelog fragments describe d44826b's semantics: between opposite requests for one reaction the later authorization wins (a surgery's adds carry its choice's moment; release is a new act); retract('all') covers prepared/held/active batches and cancelling it never revives what it overrode; host/command turn undo holds the store like hide; an imported active suppression is verified and resumed rather than taken as body proof.
… not its reconciliation state; cancel discloses released removals June #45470 (probe /tmp/june-1016-799-legacy-status-probe.ts, with the actual 64c480b writer): v2 deliveryStatus is the old writer's reconciliation state (setDesired rewrites it from the cached markerPresent without any request), not the last attempt's outcome. An unknown add reconciled onto the source read as applied -> lastAddConfirmed:1, outcomesUnrecorded:0; a confirmed add reconciled onto the source read as pending -> its attempt unrecorded. The 64c480b writer recorded a success by deleting lastError (recordSuccess) and a failure by setting it (recordFailure); nothing else touches it. Evidence now carries lastOutcome derived only from that: confirmed (no error after an attempt), not-sent (pre-write refusal), refused (Discord's definitive answer), unrecorded (anything else). outcomesUnrecorded = earlier attempts + the last when unrecorded. The old status is kept as oldDeliveryStatus, documented as reconciliation state; the view's lastAdd/RemoveConfirmed count lastOutcome confirmed. Held release actions still follow the old writer's pending work (that is what its reconciliation state legitimately says). Same probe: cancel(batch).unresolvedAttempts filtered adds only, so a released removal's unknown attempt went undisclosed while the view showed it. Cancel now counts every request the batch made (adds and released removals). Test: entries exactly as 64c480b leaves them (unknown add then source reconcile; confirmed add then source reconcile) give lastAddConfirmed 1 and outcomesUnrecorded 1; the pending removal is held; release, unknown then confirmed removal, cancel -> unresolvedAttempts 1. Outbox 30/30.
…uthful history June #45522: cancel() and cancelRetract() returned early with zero counters once the group was cancelled, so a confirmation landing after the first cancel read as confirmed:0 on a repeat while the view showed it. The history projection (confirmed; in flight; unknown, including attempts left without an outcome by a previous process) is now computed for every call, whatever each request's cancel state, in one helper (stopRequests); only the stopping records (and `cancelled`, newly stopped requests) depend on the group not being cancelled already, and a repeat writes nothing. Test: batch with one add on the wire and one due -> cancel (cancelled 1, inFlight 1) -> the in-flight add confirms -> repeat cancel (cancelled 0, inFlight 0, confirmed 1, unresolvedAttempts 0, no new journal records); the same for a retract request.
…tablishes and labels the old status as reconciliation state
…ng intent; offline branch creation settles like any body failure June #46104 (probe /tmp/june-1016-511-offline-name-probe.ts): the offline recovery called contextManager.branchAt() outside its body try/catch, after prepare. With branchName naming an existing branch it threw "Branch already exists" and left a prepared target-branch batch; starting that pre-existing branch later crash-completed it and queued an add for a message still in context. - offline: refuse an existing target name before anything is prepared (also in a dry run, which would fail the same way), and create the branch inside the body try, so a creation failure restores/confirms the source and retires the batch (or keeps it, saying so) like a failed switch or redaction. - live rollbackToMessage/suppressMessages: the same pre-check (OperatorActionError 'invalid'), before reserving or preparing; their branch creation was already inside the failure handling. Tests: offline recovery onto an existing branch with marks chosen is refused and journals nothing; an injected branchAt failure retires the batch on a confirmed source; live rollback and suppression onto an existing branch are refused with no batch. Awareness set + record-journal 87/87.
[High impact] The PR reaches Discord mark delivery and shared MCPL connection handling. Fix the offline choice checks and legacy import checks before merging. Findings
Fix with agent prompt### Issue 1
src/recovery/offline-branch.ts:186-187
`createOfflineRecoveryBranch()` does not check `marks` at runtime. A JavaScript caller passing `marks: 'addressed'`, or an object with a misspelled `scope`, reaches `selectDiscordAwarenessRefs()` without either filter. It then queues marks for every removed Discord message, including ambient messages the operator did not choose to mark.
The live methods reject these same values. Validate the offline API's choice before changing the store.
### Issue 2
src/recovery/discord-awareness-outbox.ts:1915-1918
`importLegacy()` now checks only a batch's `id` and `refs` array. A parseable ledger with malformed suppression fields is accepted instead of refused. For example, `activationPolicy: 'explicit '` makes `pendingBody` false, so a prepared suppression is discarded and startup skips its unfinished removals. The host can then serve a partly suppressed branch.
The import is recorded and the file renamed, so later starts do not retry it. Restore the field checks before committing an import.
### Issue 3
src/framework.ts:17042-17045
`runDiscordAwarenessSync()` selects servers only from work ready to send. While a server's last reaction awaits its answer, `headDispatch()` excludes that key. Calling `syncDiscordAwarenessMarkers()` then resolves without waiting for the running delivery pass.
Background delivery continues, but callers cannot rely on awaiting the explicit sync to wait for that pass. Include matching entries from `discordAwarenessDrains` when selecting servers.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryMakes Discord awareness marks opt-in and one-shot, moves their history into Chronicle, adds cancel/retract controls, and removes delivery's hold on MCPL traffic.
Reviews (2) · Last reviewed commit: "Merge main (3adbdb86) into feat/surgery-..." · Reviewed by Greptile |
…e is synced, and reports what the journal holds Greptile review of anima-research#250 (P1 "Marks return after failed scheduling", P1 "Hide marks can outlive removal"); both reproduced at dc2928c. scheduleAppliedMarks prepared its batch after the change was already applied (an in-place hide's redaction, a turn undo's switch), with only the after-append durability sync. A kill between the batch append and that sync could keep the batch (appends reach the OS at once) and lose the buffered removal; a hide keeps its branch, so startup activated the batch and marked messages the agent still sees. And when prepare() threw after its record reached the store (a failed post-append sync), the receipt said `not-scheduled` ("nothing was recorded") while the batch sat prepared for the current branch, and startup delivered it. The outbox now owns that protocol: settleApplied() builds the batch for exactly the change's branch, appends it with afterCommittedState (sync before append, the barrier activate/discard already use), and settles it like any prepared batch (activate, else retire, else unresolved). If the write fails, it reads the journal back: a batch that is not there is `not-scheduled`; one that is there is settled and the receipt follows that; if the read-back fails too, `unresolved` with the batch id. A journal that can't be read at all writes nothing (`not-scheduled`). The framework maps every settlement onto the receipt and ops alert through one path (settledMarkerReceipt) shared with rollback/suppress; its wording no longer says "batch retired" for a batch never recorded. The receipt docs (operator-log.ts, README) and describeMarkers say what not-scheduled and unresolved now cover. DiscordAwarenessPrepareInput and DiscordAwarenessSettlement are exported. Tests (test/discord-awareness-outbox.test.ts), through host/command hide over a store whose journal seams can be observed and faulted: - the removal is synced before the batch is appended; - a failed barrier sync before the append: not-scheduled, nothing recorded, startup activates nothing; - a failed sync right after the append: the batch is settled (queued) and the receipt names it; - every sync from the append on fails: unresolved, the receipt names the retained prepared batch, and startup may activate it, as it says; - the read-back after the failed write fails: unresolved, with the id. All five fail at dc2928c on their status/ordering assertions.
…e new grant is established Greptile review of anima-research#250 (P1 "First reconnect loses channel registration"), plus the same-root case Mira-1605 identified in review. A reconnect resets the server's grant (a fresh initialize is a fresh §5.3 policy epoch), but McplServerConnection.attemptReconnect paused only the data plane, and anima-research#250's reconnect listener then called readyControlPlane() before re-running registerMcplServerFeatures. So control traffic from the fresh transport reached admission against the empty grant: a channels/register sent right after initialize (as chat bridges do) was refused with -32002 ("initial policy not yet established") and never replayed, leaving the route's incoming messages unknown channels until the server registered again. Two paths led there: - a stub whose first connect failed and which reconnects while startup is still staging other servers: its control plane was still closed until the listener's new readyControlPlane() opened it (the regression); - any connection that was already open: its control plane was never closed at the boundary (pre-existing; the old barrier code paused data only, too). attemptReconnect now closes both planes with the grant reset, as initial connect holds them, and the framework's reconnect listener no longer opens the control plane early: it re-establishes the grant (policy responses are never event-buffered, so holding control cannot deadlock the exchange) and then opens the planes through readyMcplPlanes, as it already did, also when the exchange fails. Quiesce still pauses only the data plane (pauseDataPlane's doc now says that is its use). Tests: - test/mcpl-reconnect-registration.test.ts (new, with fixture reregistering-mcpl-server.mjs): the real framework, a server that registers its channel on notifications/initialized and exits once on demand; after an ordinary reconnect the fresh process's registration must be answered without error. At dc2928c it is refused -32002; with only the listener change, or only the connection change, it is still refused; both together pass. - test/mcpl-reconnect-events.test.ts: after a reconnect, a control event is held until ready(), then released (fails at dc2928c). Changelog: mcpl-reconnect-registration.fixed.md.
…n the operator log Greptile review of anima-research#250 (P2 "Offline controls skip the log"), with the extension agreed in room-245 (#48091, #48115): the CLI's own recovery. The live surfaces record every operator mutation in <store>/operator-actions.jsonl (who asked, from where, what it did, or the error), and the changelog promises that each awareness control is recorded. agent-framework-recover --awareness cancel|retract|release called the outbox directly and only printed the receipt, so the operator history lost every offline control. The CLI's recovery, which anima-research#250 lets publish marks (--marks), was not recorded either, so nothing said who chose those marks. The CLI now appends, best-effort (OperatorLog.append never throws, and the OS account lookup falls back to {via: 'cli'}): - for cancel/retract/release: kind awareness-<verb>, agent '*', params {target} or {batchId} as the live wrapper logs them, and the receipt or the error; - for a recovery: kind 'recovery', the agent, params (anchor, any suppressions, the marks choice as 'none' or {scope}), and the result (branches, messages removed and suppressed, discordRefs, markers), or the error when it is refused or fails. The requester is {via: 'cli', name: <OS account>}, as the web UI records its basic-auth user. A dry run and a list record nothing. The log path is the host's default under the store. Tests: test/recover-cli-operator-log.test.ts spawns the compiled CLI: list records nothing; cancel and retract record their receipts; a refused release records its error; a dry run records nothing; a recovery records its marks choice and the receipt it printed; a refused recovery (existing branch) records its error. Docs: the CLI help, README's offline recovery section, and the controls changelog fragment.
…he operator log Greptile review of anima-research#250 (P2 "Turn marks aren't logged"). Turn-based host/command undo accepts a marks choice and schedules marks for the messages its turns removed, but its only operator-log entries came from undoLastTurn(), one per turn, with the turn and branches. So nothing recorded who chose to publish (or not to publish) marks, while hide and rollback (message-granular undo) record their choice and receipt. After scheduling, the command now records one entry: kind 'undo-turns', the requester, params {turns: requested, marks: none | {scope}}, and result {undone, markers}. It is written whenever at least one turn was undone, `marks: none` included (agreed with Mira-1605: the local-only choice is worth recording, as hide and rollback do); a command that undid nothing changed nothing and records nothing. The per-turn undo-turn entries are unchanged. Test (test/discord-awareness-outbox.test.ts): a marked turn undo, a local one, and one with nothing left to undo log exactly two entries, each with the receipt the command returned.
…y entry Greptile review of anima-research#250 (P2 "Checkpoints repeatedly copy all history"). Every 256 entries the outbox wrote a checkpoint holding its whole reduced state, and Chronicle records are never removed, so stored copies grew quadratically with the marks ever requested. The reduced state keeps every request and every attempt, because receipts (cancel's and retract's unresolved counts, view()), operations() and authorization order read that history; so each checkpoint copied all of it. Measured with the compiled outbox (fsync stubbed, 20 marks per batch, one confirmed attempt each): - 2,000 marks / 4,200 entries: 10.8 MB with checkpoints, 1.65 MB without; opening took 0.14 s and 0.11 s; - 8,000 marks / 16,800 entries: 149.6 MB vs 6.6 MB; opening took 0.70 s with checkpoints and 1.43 s without; - 20,000 marks: 918.5 MB with checkpoints (the run without them was not completed). Rather than keep a separate summary of that history, the outbox writes no checkpoints and opening replays every entry: CHECKPOINT_EVERY, the checkpointEvery constructor option, snapshotState and the checkpoint branch of append() are gone. A journal an earlier build of this branch checkpointed is still read correctly: RecordJournal.load() starts from its latest checkpoint, and restoreState (kept, with JournalSnapshot) reduces it. RecordJournal itself is unchanged; it is the base this PR shares with anima-research#247. Agreed with Mira-1605 in room-245. Tests (test/discord-awareness-outbox.test.ts), replacing the checkpoint test: - a 300+-entry history writes no checkpoint record and a fresh reader reproduces the writer's view and operations (fails at dc2928c: one checkpoint); - a journal checkpointed by an earlier build (written through RecordJournal) and continued past it reads the same as the writer's full state; a mutant that drops the snapshot's position fails it.
…e its policy exchange settles Review of 9d17ad6 by Mira-1605 (room-245, #48850), reproduced: Greptile's first-reconnect scenario still lost its registration. A stub whose first connect failed reconnects while startup is still staging another server; its reconnect listener correctly waits for its policy exchange, but initializeMcpl's final readyMcplPlanes() opens every connection, including this one, mid-exchange, and flushes its buffered channels/register against the empty grant (-32002, "initial policy not yet established"). resume()'s readyMcplPlanes() is the same kind of caller. The framework now marks, per connection, the transport epoch whose policy exchange a reconnect has in flight (mcplPolicyExchanges, a WeakMap keyed by the connection object, so a replacement connection under the same id is unaffected). readyMcplPlanes skips a connection whose current epoch's exchange is pending; the reconnect listener clears its mark and opens the planes once the exchange settles, granted, refused or unanswered (so an MCP-only connection and host/command still come up, as at initial connect), unless a newer transport's exchange has replaced its mark, in which case that one opens them. Tests (test/mcpl-reconnect-registration.test.ts): - Mira's reproduction as a deterministic integration test: `retry` fails its first connect and holds its retry's policy answer; `stager` answers initialize only once that policy is pending, so startup's open of every plane runs mid-exchange. Its registration must stay unanswered until the policy is released, then be admitted after it. Without this commit it is answered -32002 at startup. - Unit cases on a framework stub with deferred policy answers: a global readyMcplPlanes() during the exchange opens nothing, and the exchange's settlement opens once; a superseded exchange never opens the planes of the transport that replaced it. Both fail without this commit. The fixture gains file-based coordination (fail-first, initialize-after, policy pending/release). Object.create harnesses that emit 'reconnect' initialize the new field (mcpl-reconnect-reregistration).
…arrying the control out Review of 25891f0 by Mira-1605 (room-245, #48850), reproduced: the CLI parsed --dry-run but the --awareness path ignored it, so `--awareness cancel ID --dry-run` cancelled the batch's adds and logged it, while the new help text said a dry run changes and records nothing; a "preview" of retract or release would have published removals or held marks. A control has no preview (inspect with --awareness list), so the CLI now refuses --dry-run with --awareness before it opens or creates any store, as Mira favoured, rather than simulating controls. The help text limits --dry-run to a recovery and says each control acts at once. Test (test/recover-cli-operator-log.test.ts): cancel, retract and release with --dry-run each exit 1 with the refusal; the journal (views and operations) is unchanged and nothing is logged; against a store path that doesn't exist, nothing is created. Fails without this commit (cancel exits 0).
…ach exactly their scope; malformed records fail closed Review round on PR anima-research#251 (Greptile, four findings; room-249, Petra-1733 implementing, Jasper-1732 reviewing). 1. Recovery (P1). A wait hold copied its deadline from the wait once and only ever extended it, so the hold standing for unreadable records (until = Infinity) never ended: nothing re-checked it, and its 24.8-day timer kept the infinite deadline when active() found no wait. Now a wait hold takes its deadline from active() at every check (extended, indefinite, or over: passed, released, or the stand-in lifted once the records read again), and a hold with no deadline re-checks as often as ProviderWaits re-reads unreadable records (ProviderWaits.retryReadMs, 30 s by default), since nothing announces that the records read again. releaseProviderWait's wait- hold branch now just asks the hold to re-check. 2. Acceleration release (P1). An organization-acceleration hold whose length was the provider's stated wait (uncapped since this PR) carried no waitModel, so releasing that wait left the whole agent parked until the original deadline. The hold now carries statedWait {model, ownUntil}, where ownUntil is when AF's own pacing for the failure (default + jitter, capped) would have ended it. A release covering that model shortens the hold to min(until, ownUntil): the provider's lengthening goes, AF's own pacing stays, and a release never lengthens a hold. A host hold never carries the tag; its length stays the host's. Releases of other models touch nothing. 3. Malformed records (P2). ProviderWaits.load now refuses records that parse but are not provider waits, failing closed instead of reading them as "no waits": entries of an unknown kind; entries or checkpoint waits missing a field the reduction or its readers use (agent, model, an instant-or-null deadline in Date range, reason, set time); a snapshot without a waits array, or with one (agent, model) twice. A release's by/at are never read back and are not checked (the host-command path records an unvalidated requesterName as by). RecordJournal reports a checkpoint whose snapshot is null as no checkpoint while still skipping the entries it covers, so when it says none, ProviderWaits looks for records of the journal's public checkpointType. The checks live in ProviderWaits, not RecordJournal: the journal leaves meaning to its caller, and f7e14f6/2153e3d are the shared base anima-research#247, anima-research#248 and anima-research#250 carry unchanged. 4. Model scope (P2). Both operator surfaces now refuse a model that is not a nonempty string before anything changes (shared isReleaseModel): the WS host.releaseProviderWait verb throws, the MCPL release-provider-wait host command returns {ok: false}. Before, the WS verb widened such a model to every model, and the host command passed it through unvalidated (a null recorded an every-model release while lifting nothing in process). Omitted or '*' still means every model. releaseCompressionPause's signature and calling condition are unchanged; malformed models no longer reach it as "every model". Tests (provider-waits.test.ts): 8 malformed shapes each fail closed after a reopen; a release with an odd `by` still reads; a hold for unreadable records ends by its own re-check once they read again; once readable, a hold takes the recorded finite deadline rather than ending; an acceleration hold's stated wait released leaves AF's own pacing and another model's release leaves it; a host hold survives a release; malformed models are refused at both surfaces and '*' still means every model. At 28369bb with only this test file, the 12 behavioural cases fail and the two guards (odd `by`, host hold) pass. The changelog fragment names the malformed-record and resume behaviour, the refused malformed model, and the acceleration/host-hold release semantics.
Reading main's writeRecoveryFile and syncRecoveryDirectory showed two ways the ledger's write fell short of the rule the framework already keeps for its recovery files: - it synced the ledger's directory but not that directory's parent, whose entry for the directory is new when a write had to make it; - it opened and synced the directory on every platform and failed the write on any refusal. On win32 a directory can't be opened, and a filesystem that can't sync a directory answers EINVAL or EBADF, which would come again on every write: each write would report as not complete though its rename had landed. Now syncDirectory skips win32 and lets EINVAL and EBADF go, as syncRecoveryDirectory does, and the write syncs the directory and its parent. Any other failure still fails the write, where syncRecoveryDirectory raises an alert: the act reports from what the file holds, as a write that didn't complete, which the permission tests pin. A new test makes the store's directory, the ledger directory's parent, unreadable: a retirement's write fails at the parent's sync and reads back as retired. The win32, EINVAL and EBADF paths aren't produced by any test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…of the ledger rebuild From an internal review of the whole change against main, and a reader's probes of the same head. The write rule: writeRecoveryFile's whole rule. - A directory that can't be synced after a write's rename is reported, and the write stands: the framework raises the `recovery-sync` ops alert in syncRecoveryDirectory's words (onUnsyncedWrite; offline, a stderr line). So a write that throws has left the ledger as it was. Before, a failed directory sync failed the act while the file held its change: a retract reported failure and never started its delivery, a cancel likewise, and a claim left its reaction `sending` with nothing sent. - With that, the "reached the file before its write failed" branches go (settleActivation's, failedPrepare's, retireAfterRestore's), and so do the queued settlement's `error` and the `unresolved` status: a batch that can be neither activated nor retired stays prepared, and a start retires it unsent, so its receipt is `not-scheduled`, whose error says which, with the `discord-awareness-not-scheduled` alert a retired batch raises. retireAfterRestore's `discord-awareness-unresolved` alert, for a failed suppression whose pending removals stay, is kept. For a suppression the texts name the first start on its branch. The offline recovery's failed retirement says the same as the host's: the batch stays prepared. batchStatus, which only those read-backs used, goes. - An act that throws drops what it changed in memory and reads the file back, whatever threw: a start whose announcement threw had kept its retirements in memory only. - A claim first records as unanswered a request of its server still marked on the wire: a pass claims only once its last answer is written, so such a request is one whose answer couldn't be written. It may land, and what it served is owed again. Delivery. - A trigger that joins a running pass marks it, and when its claim finds nothing the pass tries once more what it already tried, under the same bound. A pass that ends before serving a join (its route went away, as a restarted server's old connection does, or a write failed) starts a pass of its own for that server once it has left. - A JSON-RPC error answer that is a refusal no retry can change is final, as main took any failure's text; other error answers stay unanswered. - The server's words are redacted with its configuration before they are logged or alerted, then bounded. - The post-claim check is for stop() alone: a route that went away refuses the request itself as not-sent. - The channel sent and the ledger's keys use one helper, discordChannelOf: one channel spelled two ways is one reaction. - cancelDiscordAwareness and retractDiscordAwareness are operator operations, which stop() waits for. The table. - `lastError` goes: nothing but tests read it, and the drain logs each failure. `failed` stays: a cancel resets attempts on a failed entry, so it can't be derived. - A cancel's receipt counts the reactions its act still wants, a later act's reactions included where the act still wants them too. A repeated cancel finds nothing, and writes nothing. - legacyAdded and the rewrite's counts take a ref listed twice once. The version refusal quotes a version that isn't a number. An unknown control target's refusal names the list. boundDiscordAwarenessText keeps no head below a small bound. Text. The hiddenRefs comment says hide places no framework marks. The README says the ledger is read once and again after a failed write, when a retryable mark is sent again, the write rule, and the receipt statuses; the fragments say the write rule, the constructor's option and that `unresolved` is gone. Test comments that cited a removed suite no longer do, and the baseline test says what its spawn reads. Tests. New: the unsynced directory and parent standing and reported; a claim releasing a request whose answer couldn't be written; an act that throws after changing the document; one channel spelled two ways; routes and emoji as separate reactions; fresh attempts for a new act; may-land kept after a later confirmation; answers refused off the wire; the cancel receipts; cancel and retract as operator operations; a joined pass whose route went away; an operator's sync joining a running pass; the drain's classification extended to a thrown refusal, a refusal past the bound, redaction and the channel sent; the shape refusals for each field; a message stored twice and addressed once; acts serialized, shown by holding a write: the next act hasn't started until it's done; a suppression without a ledger; an offline version 2 rewrite; the startup probe answered, not refused; a delivery whose answer's write fails, reported where it started and asked again by the next pass. The offline cases of a retirement that reached the file, or couldn't be read back, go with the rule that made them. The surgery receipt tests hold replies under the longest deadline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ract's unsynced write end to end - writeLedgerFile calls onUnsyncedWrite inside its own try, so the rule "a write that throws has left the file as it was" holds by construction: a report that throws after the rename leaves the write standing. The framework's callback and the default only alert or log today. A ledger test pins it with a throwing callback. - surgery-marks-receipt: a retract whose ledger directory can't be synced after its write, end to end. It returns its receipt, the framework raises `recovery-sync` with the ledger's words, and its removals are sent. - discord-awareness-delivery's deadline test waits for the drain's log line instead of asserting it as soon as the file shows the answer: the file holds the answer from the rename, and the drain logs it only once the write, the directories' syncs included, is done. On a loaded host the full suite failed it: at the assertion the log held the pass's opening line and the connection's line about the abandoned request, and not yet the drain's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…em 10) The rent pass on anima-research#250 (Esther-2584's, with Ada's calls) cut marks for hide and turn undo. On main neither made a batch: a turn undo only re-derived marks after its branch move, which the one-shot change removes, and hide's 💤 is discord-mcpl's own (0.3.0's /hide reacts to its hiddenRefs itself), so that choice belongs where the mark is made. - host/command hide and the undo by turns are main's again (9e3e407): no marks choice, no `markers` in their answer, no `undo-turns` operator-log entry, and no store hold. The hold for hide and turn undo goes with the body-integrity parts to their own pull request. - host/command refuses a marks choice a command can't carry out: only an undo by messages, which rolls back, marks what it removes. A mistyped scope is refused as before. - Gone: scheduleAppliedMarks, addressingSnapshot, runUndoLastTurn's holdsStore, the outbox's settleApplied, and their tests (a hide's or turn undo's marks and operator-log entries, the batch's write order over a faulty journal, the store hold). - New test: hide and an undo by turns refuse a marks choice before removing anything, and neither records a batch; a hide without one logs no marks. - The README and the changelog fragments say the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t pass, item 8) Ada's call on the rent pass: the MCPL reconnect fixes leave anima-research#250 for a pull request of their own on main, since main has the defect too (Esther-2584's J20464: main refuses a registration sent right after a reconnect's initialize, -32002), so the fix reaches every host's next release whether or not anima-research#250 is ready. Crispin-2713 carries it. The line between it and what anima-research#250 keeps was drawn with him in room-203: the exchange marker goes with the fixes, since main's own gates open a connection mid-exchange too, and without it anima-research#250 opens planes mid-exchange only where main can (its two openers of every connection, startup and resume(), are among main's five). What anima-research#250 keeps for its gate's removal (item 3): - readyMcplPlanes as the one opener, with the quiesce check only: no marker and no isConnected skip, so it readies a disconnected stub as main's gate does, and the stub's buffered connect-failed comes out at startup's open; - a connect runs its exchange, then opens its own planes; a runtime connect or restart whose policy registration throws closes the connection for good in the connect's own catch, as main does since anima-research#459; - the reconnect handler runs its exchange, then opens its connection's planes. Out of anima-research#250, to the reconnect pull request: settlePolicyExchange and mcplPolicyExchanges, the transport-epoch checks (in the reconnect handler, the connect and registerMcplServerFeatures), every change to server-connection.ts but four comments (control traffic held across a reconnect, the transport boundary's keep and drop rules, the flush order, the responder's epoch binding, replaceHeld and the held-request count across a boundary, the handshake's close), their tests in mcpl-reconnect-events and mcpl-reconnect-registration, and mcpl-reconnect-registration.fixed.md. - server-connection.ts is main's (9e3e407) but for the comments that named the awareness gate, which this branch removes; they now say what holds the data plane without it. - mcpl-reconnect-events.test.ts is main's again. - anima-research#459's tests stay, since main's sit in the awareness-gate test file this branch deletes: they move to mcpl-runtime-connect-failure.test.ts, without the check that the marker held the connection at its close. The fixture they and failed-create-releases use, reregistering-mcpl-server.mjs, stays as it is. - mcpl-reconnect-reregistration's harness loses the marker and the connection's epoch fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…quest (rent pass, item 9) Ada's call on the rent pass: the suppression name claims, the switch and redo refusals, giving up a suppression whose branch is gone, and refusing names and switches while the journal can't be read leave anima-research#250 for a pull request of their own after it, carried by Ezra-2423. They read pending suppressions from the ledger the rebuild reshapes, so building them on the table writes them once, and their own rent pass weighs each part. With them goes resuming the body of an imported suppression 0.22.x had activated, agreed with Ada beside the ledger's design: main resumes only a prepared one. The store hold for hide and turn undo went with item 10's commit. What returns is main's behaviour for each: - no name is claimed or refused: rollback, suppression, the offline recovery and the API's branch.create make a branch under any free name, and newOperatorBranchName passes over only the names the store holds; - switchBranch and redo refuse no target; - a suppression whose body is pending waits for a start on a branch with its name, whether or not that branch exists, and nothing gives it up; startup logs nothing about other branches' pending bodies; - only a prepared suppression is pending: an imported one 0.22.x activated is taken as finished, as 0.22.x took it. Gone: checkDiscordAwarenessBranchName (a public method), the switch and redo refusal, logPendingDiscordSuppressions, recoverAtStartup's branchExists and its result's pending list, the retirement's reason (only one was left), the suppression-abandoned record and the batch's suppressionAbandoned, the outbox's readable getter, the default-name pass over claims, and the texts that named any of these. completeSuppression now always retires the batch it completes, since only a prepared batch reaches it. The failed suppression's errors say a start on a branch with its target's name would finish its removals, and the fork's error no longer leans on a refusal. Tests: the claim, refusal, give-up, pending-log and imported-active resume tests go; the startup, sync and report tests lose branchExists and reason; the texts follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…N ledger (rent pass, item 7)
Ada's call on the rent pass (room-203 #148426, item 7): the journal of
requests and attempts goes, and the ledger is a table with one entry per
reaction in main's JSON file, as version 3, migrated in place, under the
seven properties of her call. The design is mine as Ada approved it
(#148577, #148584), with the writes as she took them after the timing
(#148616, #148621).
The file is `<store>/recovery/discord-awareness-outbox.json`, main's path
(or discordAwarenessOutboxPath), holding { version: 3, moment, batches,
retracts, entries }. `moment` is the ledger's counter: a choice (prepare),
an activation and a retract each take the next one, and clock times are
only shown. An entry is one reaction, keyed by route, channel, message and
emoji: `wanted` (present, absent, or null once every act that set it is
cancelled), `wantedBy`, `retracted` (the moment of its latest retract,
cancelled or not), `due` (the moment of the act that owes a request now),
`attempts` at that due, `sending` (the request written ahead, with the due
it serves), `confirmed` (Discord's last answer, or unknown), `mayLand`
(actions of requests never answered; nothing takes one out), `failed` and
`lastError`.
The rules, as properties on the table:
- prepare writes the batch with its `chosen` moment before the body
change. activate syncs the store, then wants each ref present and owes it
a request, except where the entry's `retracted` is later than `chosen`,
even if that retract was cancelled since: a retract made during the
surgery wins and cancelling it revives nothing;
- a claim writes `sending` before its request can leave, and the drain sends
only after that write is on disk; an entry is claimed again only after its
answer is written;
- an unanswered request adds its action to `mayLand` and makes `confirmed`
unknown. recordUnanswered is the one place that does it, for an answer the
connection couldn't get and for a request a previous process left on the
wire, which a start records the same way (Ada's first point);
- an answer ends what is owed only if it is for the due its request served:
one for an older act updates `confirmed` and `mayLand` alone (the rule
Ada's list missed, #148584);
- three unconfirmed attempts at one due, or a refusal for good, end what is
owed (`failed`); a request refused before it left doesn't count;
- a cancel takes its act out of `wantedBy`, and an entry no act wants is owed
nothing; a request on the wire still records its answer.
The writes (#148621): the document is kept in memory by its one writer and
written compact. Acts run one at a time through a promise queue, each
written before the next starts: mutate, then write. A write goes to a
temporary file through fs/promises, synced, renamed over the ledger, and the
directory synced, with the store synced first for a write that activates,
completes or retires a batch. A write that fails reads the file back, and
the act reports from what the file holds (settleActivation, discard's
read-back and failedPrepare say what reached the file). Views read memory.
Writing calls are async, and so are the framework's cancelDiscordAwareness
and retractDiscordAwareness (new in this PR, so no released contract moves).
No incremental serialization, as Ada decided. At 10,000 entries the
stringify holds the loop about 5 ms at the median on Bun and 16 ms on Node
24 (~/.local/state/kestrel-2409/af250/measure/ledger-async-probe.mjs,
with its output ledger-async-lethe-224430.txt beside it).
The drain keeps its registry invariant with async claims: a pass leaves in
the microtasks after the claim that found nothing, and every act that makes
a request owed resolves only after its write, so a request made owed later
starts a new pass. A request written ahead as stop() begins, or as its
route goes, is recorded not-sent and stays owed.
The migration runs at the first act on a version 2 file, rewritten as
version 3 at once: batches keep id, agent, branches, emoji, createdAt, refs
and intervals, take scope legacy and `legacyAdded`; an active one stays
active, a prepared one is retired unless it is a suppression (explicit, with
intervals), which stays prepared for a start on its branch; 0.22.x's unsent
work is dropped and announced before the write; no entries are made. A view
before that act refuses a version 2 file. Version 1 is refused with the
wording Ada took (#137764), adjusted to "reads versions 2 and 3"; another
version, or a file not the shape version 3 promises, is refused, and a start
stops on it, as on main. Going back (Ada's second point): 0.22.x reads only
versions 1 and 2 and its start stops on version 3 (checked at v0.22.2 and
v0.22.3, whose ledger is main's), so the README and the breaking fragment
say to move the file aside first.
A framework with neither storePath nor discordAwarenessOutboxPath has no
ledger again, as on main: it refuses a marks choice before anything is
planned (refuseMarksWithoutLedger, `invalid`), its cancel and retract refuse,
its list is empty, and a suppression there runs with no record to resume it
from, as it always has.
Receipts and views: entries count by state (queued, sending, present,
absent, unknown, failed, untouched); `mayStillLand` replaces
`unresolvedAttempts`; the cancel receipt keeps `cancelled` and `confirmed`;
the retract receipt keeps `removalsQueued`, `addsSuperseded` (entries whose
owed add it replaced) and `keysWithUnresolvedAdds`, and
`unresolvedAddAttempts` goes. A batch's status is prepared, active or
retired. The surgery `markers` receipt keeps its shape. The offline result
has `outboxPath` again, as on main, and `suppressionJournal` is
`suppressionRecord`.
Gone: the journal records, their replay, format and lift texts, the import
and its `.migrated-v2` rename, operations(), operationStatus(),
pendingDispatches(), claimDispatch(), recordDispatching(), the op, attempt
and dispatch types, DISCORD_AWARENESS_RECORD_TYPE, activationPolicy and
suppressionComplete. src/record-journal.ts and its test are main's again:
`checkpoints: false` was the journal's.
Docs: the README's offline and live sections describe the ledger, its
receipts, the no-ledger refusal, the migration and how to go back; its
sentence about resuming an imported active suppression, stale since the
item 9 commit, goes. The four anima-research#250 fragments and the stop fragment say
ledger, and the one-shot fragment says writes are durable now and leave the
loop free but for serializing.
Tests: discord-awareness-outbox is rewritten for the table: each rule
above, the migration, the refusals, and failed writes made real by the
directory's permissions (a write that fails before the rename, one whose
rename lands and whose directory sync fails, a file that can't be read
back), skipped as root. The framework seams port to the async calls, and
three are new: a request written ahead as stop() begins, a framework with
no ledger, and a start on an unreadable ledger. delivery, stop-drains,
baseline, offline recovery, surgery receipts and stop-with-host-operations
read the file or the views instead of operations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…anima-research#506's words and F240 main moved 331 commits on from 9e3e407, among them anima-research#262 (the modern MCP client and the legacy engine's protocol boundary), anima-research#506 (the agent's words for failed requests), anima-research#532, anima-research#537, anima-research#210 and anima-research#542 (F240: a redaction that can't read its configuration withholds the text). What anima-research#250 removes stays removed, and what main added keeps working beside it. Conflicts: - src/framework.ts, imports: main's new imports, with anima-research#250's McplRequestError beside McplProtocolVersionError and McplUnreapedLaunchError. - src/framework.ts, the barrier: main changed failMcplDataPlaneGate's recycle (a connection it couldn't close takes its tools off the list). anima-research#250 removes the gate and its recycle, so that change has nowhere to go; the close for good it rests on (closeMcplServerForGood) is main's and stays, and a runtime connect whose policy registration throws still goes through it. - src/framework.ts, the connect: main split connectMcplServerInternal into the id hold and connectHeldMcplServer, with a modern family, a stop check and an abort. That structure is main's. anima-research#250's parts go into it: the parameter is `staged`, the ledger is read first only by a legacy connect that derives the protective default (anima-research#542's sentence about a ledger that breaks after that read stays), listeners are wired before any plane opens, a runtime connect whose policy registration throws closes for good, and a staged startup connect returns its connection while a runtime one opens its planes and starts delivery in the background before returning it. - src/framework.ts, the restart: main's restart (its in-flight, claim, id and prefix checks, and its claims) with anima-research#250's rule for the ledger read: only a legacy server that derives the default reads it before disconnecting. - src/mcpl/server-connection.ts: main's file, which moved the reconnect into reconnectOnce, with anima-research#250's five rewrites of comments that named the awareness barrier. - test/failed-create-releases: main's two-engine test, with anima-research#250's legacy server (`chat`, the reregistering fixture) and no ledger options. - test/mcpl-reconnect-reregistration, test/stop-with-host-operations: wireMcplEvents takes the config now. - test/stop-ends-awareness-drains: anima-research#250's assertions, with anima-research#506's words for a request the close ended. - test/placed-reaction-baseline: anima-research#542's two tests go in ahead of anima-research#250's restart tests. The reconnect one breaks the ledger with breakLedger, since version 3 reads the file only when it has no document in memory. - test/mcpl-awareness-barrier.test.ts and its fixture stay deleted. Of main's changes to them, the deadline wording and the generation tests' signatures go with the file; the relist after a failed close is anima-research#459's test, which anima-research#250 keeps in mcpl-runtime-connect-failure.test.ts, so that assertion moves there. Outside the conflicts: - test/fixtures/reregistering-mcpl-server.mjs, anima-research#250's own fixture, answers initialize with protocolVersion 2024-11-05: main's legacy engine refuses an initialize answer without it (anima-research#262), and main gave its own fixtures the field. - test/discord-awareness-delivery's inbound test passes a config to wireMcplEvents, which records through that server's redactor now. - main's modern-MCP connect and restart read no ledger and install no gate, so they meet nothing anima-research#250 removed. Two of main's comments, and the modern client's pending fragment, named an awareness gate as MCPL surface a modern server lacks; with no gate left, they no longer name it. - main's modern test in placed-reaction-baseline breaks the ledger with breakLedger too, for the same reason. - anima-research#506's words reach anima-research#250's own assertions: a hung reaction's recorded error says the server didn't answer within its deadline, and a request the connection's loss ended says the connection closed before it answered. anima-research#250's test of a request the host's close ends goes: main's "closing the connection while a request awaits its answer" pins the same outcome, and stop-ends-awareness-drains pins its words. - test/mcp-server-ownership: main's two tests of the gate's recycle (an accounting failure recycling a server whose child can't be reaped, with and without reconnect) call installMcplDataPlaneGate and failMcplDataPlaneGate, which anima-research#250 removes, so they go with the gate. The close for good they lead to is still pinned for a runtime connect's failure, in mcpl-runtime-connect-failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e held-request budget Greptile's review of anima-research#544: at a transport boundary, kept incoming messages and pushes that came as requests still counted against anima-research#387's budget of 10,000 held requests. With 10,000 kept, the next process's channels/register, which a reconnect now holds, was refused with -32000, and every kept message for its channel was then refused as unknown at the flush. The budget refuses a request rather than dropping it because its server waits on the answer and can send it again. A kept request's server is gone, and its answer is bound to a closed transport (sameTransport), so: - kept requests are counted apart (keptRequests), out of the budget, with a bound of their own at the same 10,000, past which one is dropped and logged, there being no one to refuse. The bound holds where it's held and where boundaries join (replaceHeld), since boundaries with no flush between them each keep what the next transport held; the earliest are kept at both; - the notification cap counts notifications only; - a flush holds what it can't admit yet again with `kept` intact (planeReadyFor, which emit's admission gate now uses too), so a control-plane flush doesn't turn kept requests into live ones; - both flushes reset the counts through replaceHeld([]). Tests (mcpl-reconnect-events): anima-research#250's test that the next transport's request is refused past 10,000 kept turns round, and new ones admit the next process's registration ahead of 10,000 kept messages, drop and log a late request past the kept bound, keep the bound across three boundaries with no flush between, keep kept items out of the budget across a control-plane flush, and hold the next transport's notifications beside 10,000 kept requests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The MCPL reconnect fixes leave anima-research#250 for a pull request of their own on main, since main has the defect too (main refuses a registration sent right after a reconnect's initialize, -32002), so the fix reaches every host's next release whether or not anima-research#250 is ready. The line between that pull request and what anima-research#250 keeps: the exchange marker goes with the fixes, since main's own gates open a connection mid-exchange too, and without it anima-research#250 opens planes mid-exchange only where main can (its two openers of every connection, startup and resume(), are among main's five). What anima-research#250 keeps for its gate's removal: - readyMcplPlanes as the one opener, with the quiesce check only: no marker and no isConnected skip, so it readies a disconnected stub as main's gate does, and the stub's buffered connect-failed comes out at startup's open; - a connect runs its exchange, then opens its own planes; a runtime connect or restart whose policy registration throws closes the connection for good in the connect's own catch, as main does since anima-research#459; - the reconnect handler runs its exchange, then opens its connection's planes. Out of anima-research#250, to the reconnect pull request: settlePolicyExchange and mcplPolicyExchanges, the transport-epoch checks (in the reconnect handler, the connect and registerMcplServerFeatures), every change to server-connection.ts but four comments (control traffic held across a reconnect, the transport boundary's keep and drop rules, the flush order, the responder's epoch binding, replaceHeld and the held-request count across a boundary, the handshake's close), their tests in mcpl-reconnect-events and mcpl-reconnect-registration, and mcpl-reconnect-registration.fixed.md. - server-connection.ts is main's (9e3e407) but for the comments that named the awareness gate, which this branch removes; they now say what holds the data plane without it. - mcpl-reconnect-events.test.ts is main's again. - anima-research#459's tests stay, since main's sit in the awareness-gate test file this branch deletes: they move to mcpl-runtime-connect-failure.test.ts, without the check that the marker held the connection at its close. The fixture they and failed-create-releases use, reregistering-mcpl-server.mjs, stays as it is. - mcpl-reconnect-reregistration's harness loses the marker and the connection's epoch fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…quest The suppression name claims, the switch and redo refusals, giving up a suppression whose branch is gone, and refusing names and switches while the journal can't be read leave anima-research#250 for a pull request of their own. They read pending suppressions from the ledger the rebuild reshapes, so built on the table they are written once, and that change weighs each part on its own. With them goes resuming the body of an imported suppression 0.22.x had activated: main resumes only a prepared one. The store hold for hide and turn undo went in the commit that took their marks out. What returns is main's behaviour for each: - no name is claimed or refused: rollback, suppression, the offline recovery and the API's branch.create make a branch under any free name, and newOperatorBranchName passes over only the names the store holds; - switchBranch and redo refuse no target; - a suppression whose body is pending waits for a start on a branch with its name, whether or not that branch exists, and nothing gives it up; startup logs nothing about other branches' pending bodies; - only a prepared suppression is pending: an imported one 0.22.x activated is taken as finished, as 0.22.x took it. Gone: checkDiscordAwarenessBranchName (a public method), the switch and redo refusal, logPendingDiscordSuppressions, recoverAtStartup's branchExists and its result's pending list, the retirement's reason (only one was left), the suppression-abandoned record and the batch's suppressionAbandoned, the outbox's readable getter, the default-name pass over claims, and the texts that named any of these. completeSuppression now always retires the batch it completes, since only a prepared batch reaches it. The failed suppression's errors say a start on a branch with its target's name would finish its removals, and the fork's error no longer leans on a refusal. Tests: the claim, refusal, give-up, pending-log and imported-active resume tests go; the startup, sync and report tests lose branchExists and reason; the texts follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…N ledger
The journal of requests and attempts goes, and the ledger is a table with
one entry per reaction in main's JSON file, as version 3, migrated in place.
The writes were chosen after timing them.
The file is `<store>/recovery/discord-awareness-outbox.json`, main's path
(or discordAwarenessOutboxPath), holding { version: 3, moment, batches,
retracts, entries }. `moment` is the ledger's counter: a choice (prepare),
an activation and a retract each take the next one, and clock times are
only shown. An entry is one reaction, keyed by route, channel, message and
emoji: `wanted` (present, absent, or null once every act that set it is
cancelled), `wantedBy`, `retracted` (the moment of its latest retract,
cancelled or not), `due` (the moment of the act that owes a request now),
`attempts` at that due, `sending` (the request written ahead, with the due
it serves), `confirmed` (Discord's last answer, or unknown), `mayLand`
(actions of requests never answered; nothing takes one out), `failed` and
`lastError`.
The rules, as properties on the table:
- prepare writes the batch with its `chosen` moment before the body
change. activate syncs the store, then wants each ref present and owes it
a request, except where the entry's `retracted` is later than `chosen`,
even if that retract was cancelled since: a retract made during the
surgery wins and cancelling it revives nothing;
- a claim writes `sending` before its request can leave, and the drain sends
only after that write is on disk; an entry is claimed again only after its
answer is written;
- an unanswered request adds its action to `mayLand` and makes `confirmed`
unknown. recordUnanswered is the one place that does it, for an answer the
connection couldn't get and for a request a previous process left on the
wire, which a start records the same way;
- an answer ends what is owed only if it is for the due its request served:
one for an older act updates `confirmed` and `mayLand` alone;
- three unconfirmed attempts at one due, or a refusal for good, end what is
owed (`failed`); a request refused before it left doesn't count;
- a cancel takes its act out of `wantedBy`, and an entry no act wants is owed
nothing; a request on the wire still records its answer.
The writes: the document is kept in memory by its one writer and
written compact. Acts run one at a time through a promise queue, each
written before the next starts: mutate, then write. A write goes to a
temporary file through fs/promises, synced, renamed over the ledger, and the
directory synced, with the store synced first for a write that activates,
completes or retires a batch. A write that fails reads the file back, and
the act reports from what the file holds (settleActivation, discard's
read-back and failedPrepare say what reached the file). Views read memory.
Writing calls are async, and so are the framework's cancelDiscordAwareness
and retractDiscordAwareness (new in this PR, so no released contract moves).
Nothing is serialized incrementally: a missed invalidation would write a
stale table. At 10,000 entries the stringify holds the loop about 5 ms at
the median on Bun and 16 ms on Node 24, by a probe of these writes.
The drain keeps its registry invariant with async claims: a pass leaves in
the microtasks after the claim that found nothing, and every act that makes
a request owed resolves only after its write, so a request made owed later
starts a new pass. A request written ahead as stop() begins, or as its
route goes, is recorded not-sent and stays owed.
The migration runs at the first act on a version 2 file, rewritten as
version 3 at once: batches keep id, agent, branches, emoji, createdAt, refs
and intervals, take scope legacy and `legacyAdded`; an active one stays
active, a prepared one is retired unless it is a suppression (explicit, with
intervals), which stays prepared for a start on its branch; 0.22.x's unsent
work is dropped and announced before the write; no entries are made. A view
before that act refuses a version 2 file. Version 1 is refused, its
wording now "reads versions 2 and 3"; another
version, or a file not the shape version 3 promises, is refused, and a start
stops on it, as on main. Going back: 0.22.x reads only
versions 1 and 2 and its start stops on version 3 (checked at v0.22.2 and
v0.22.3, whose ledger is main's), so the README and the breaking fragment
say to move the file aside first.
A framework with neither storePath nor discordAwarenessOutboxPath has no
ledger again, as on main: it refuses a marks choice before anything is
planned (refuseMarksWithoutLedger, `invalid`), its cancel and retract refuse,
its list is empty, and a suppression there runs with no record to resume it
from, as it always has.
Receipts and views: entries count by state (queued, sending, present,
absent, unknown, failed, untouched); `mayStillLand` replaces
`unresolvedAttempts`; the cancel receipt keeps `cancelled` and `confirmed`;
the retract receipt keeps `removalsQueued`, `addsSuperseded` (entries whose
owed add it replaced) and `keysWithUnresolvedAdds`, and
`unresolvedAddAttempts` goes. A batch's status is prepared, active or
retired. The surgery `markers` receipt keeps its shape. The offline result
has `outboxPath` again, as on main, and `suppressionJournal` is
`suppressionRecord`.
Gone: the journal records, their replay, format and lift texts, the import
and its `.migrated-v2` rename, operations(), operationStatus(),
pendingDispatches(), claimDispatch(), recordDispatching(), the op, attempt
and dispatch types, DISCORD_AWARENESS_RECORD_TYPE, activationPolicy and
suppressionComplete. src/record-journal.ts and its test are main's again:
`checkpoints: false` was the journal's.
Docs: the README's offline and live sections describe the ledger, its
receipts, the no-ledger refusal, the migration and how to go back; its
sentence about resuming an imported active suppression, stale since the
previous commit, goes. The four anima-research#250 fragments and the stop fragment say
ledger, and the one-shot fragment says writes are durable now and leave the
loop free but for serializing.
Tests: discord-awareness-outbox is rewritten for the table: each rule
above, the migration, the refusals, and failed writes made real by the
directory's permissions (a write that fails before the rename, one whose
rename lands and whose directory sync fails, a file that can't be read
back), skipped as root. The framework seams port to the async calls, and
three are new: a request written ahead as stop() begins, a framework with
no ledger, and a start on an unreadable ledger. delivery, stop-drains,
baseline, offline recovery, surgery receipts and stop-with-host-operations
read the file or the views instead of operations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arch#506's words and anima-research#542 main moved 331 commits on from 9e3e407, among them anima-research#262 (the modern MCP client and the legacy engine's protocol boundary), anima-research#506 (the agent's words for failed requests), anima-research#532, anima-research#537, anima-research#210 and anima-research#542 (a redaction that can't read its configuration withholds the text). What anima-research#250 removes stays removed, and what main added keeps working beside it. Conflicts: - src/framework.ts, imports: main's new imports, with anima-research#250's McplRequestError beside McplProtocolVersionError and McplUnreapedLaunchError. - src/framework.ts, the barrier: main changed failMcplDataPlaneGate's recycle (a connection it couldn't close takes its tools off the list). anima-research#250 removes the gate and its recycle, so that change has nowhere to go; the close for good it rests on (closeMcplServerForGood) is main's and stays, and a runtime connect whose policy registration throws still goes through it. - src/framework.ts, the connect: main split connectMcplServerInternal into the id hold and connectHeldMcplServer, with a modern family, a stop check and an abort. That structure is main's. anima-research#250's parts go into it: the parameter is `staged`, the ledger is read first only by a legacy connect that derives the protective default (anima-research#542's sentence about a ledger that breaks after that read stays), listeners are wired before any plane opens, a runtime connect whose policy registration throws closes for good, and a staged startup connect returns its connection while a runtime one opens its planes and starts delivery in the background before returning it. - src/framework.ts, the restart: main's restart (its in-flight, claim, id and prefix checks, and its claims) with anima-research#250's rule for the ledger read: only a legacy server that derives the default reads it before disconnecting. - src/mcpl/server-connection.ts: main's file, which moved the reconnect into reconnectOnce, with anima-research#250's five rewrites of comments that named the awareness barrier. - test/failed-create-releases: main's two-engine test, with anima-research#250's legacy server (`chat`, the reregistering fixture) and no ledger options. - test/mcpl-reconnect-reregistration, test/stop-with-host-operations: wireMcplEvents takes the config now. - test/stop-ends-awareness-drains: anima-research#250's assertions, with anima-research#506's words for a request the close ended. - test/placed-reaction-baseline: anima-research#542's two tests go in ahead of anima-research#250's restart tests. The reconnect one breaks the ledger with breakLedger, since version 3 reads the file only when it has no document in memory. - test/mcpl-awareness-barrier.test.ts and its fixture stay deleted. Of main's changes to them, the deadline wording and the generation tests' signatures go with the file; the relist after a failed close is anima-research#459's test, which anima-research#250 keeps in mcpl-runtime-connect-failure.test.ts, so that assertion moves there. Outside the conflicts: - test/fixtures/reregistering-mcpl-server.mjs, anima-research#250's own fixture, answers initialize with protocolVersion 2024-11-05: main's legacy engine refuses an initialize answer without it (anima-research#262), and main gave its own fixtures the field. - test/discord-awareness-delivery's inbound test passes a config to wireMcplEvents, which records through that server's redactor now. - main's modern-MCP connect and restart read no ledger and install no gate, so they meet nothing anima-research#250 removed. Two of main's comments, and the modern client's pending fragment, named an awareness gate as MCPL surface a modern server lacks; with no gate left, they no longer name it. - main's modern test in placed-reaction-baseline breaks the ledger with breakLedger too, for the same reason. - anima-research#506's words reach anima-research#250's own assertions: a hung reaction's recorded error says the server didn't answer within its deadline, and a request the connection's loss ended says the connection closed before it answered. anima-research#250's test of a request the host's close ends goes: main's "closing the connection while a request awaits its answer" pins the same outcome, and stop-ends-awareness-drains pins its words. - test/mcp-server-ownership: main's two tests of the gate's recycle (an accounting failure recycling a server whose child can't be reaped, with and without reconnect) call installMcplDataPlaneGate and failMcplDataPlaneGate, which anima-research#250 removes, so they go with the gate. The close for good they lead to is still pinned for a runtime connect's failure, in mcpl-runtime-connect-failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
37c1acb to
85a714b
Compare
|
What came of it:
The change is +6,975 −4,059 against Written by Kestrel-2409. — Daniel-2612 |
…s new grant, whatever gate completes meanwhile A server's channels/register sent right after a reconnect's initialize was refused (-32002) against the empty grant the reconnect left, and never replayed, so the route's incoming messages stayed unknown channels until the server registered again. The reconnect closed only the data plane. And every gate's completion (startup's staged open, a quiesce resume, an awareness sync, another server's list change or reconnect) readied every connection, including one whose policy exchange was still in flight, so a hold could be undone by the next gate, the data plane's too. These are agent-framework #250's reconnect fixes, split out so the fix reaches hosts whether or not #250 is ready, and built on main's awareness gate: - A reconnect closes both planes when it adopts its transport, as initial connect does. - The framework marks a connection's policy exchange in flight (mcplPolicyExchanges): a reconnect's from its 'reconnect' event, a connect's from its registration (addServer's new onRegister runs just before the registry registers the connection), through a runtime connect's awareness accounting. The gate's release and completion pass a marked connection by. If the gate a connect or reconnect installed was replaced during the exchange, the connection is opened once it settles, as far as the current gate and quiesce allow, and readied between transports if its transport dropped. Each way out of a failed connect lifts the marker after any close. A runtime connect runs its exchange only on the transport it registered; if a reconnect replaced it during the awareness accounting, the reconnect's exchange covers the new one. - At a transport boundary, held incoming messages and pushes are kept for the next grant, and the other messages the server sent, which speak for its process (such as a request, a tools/observe filter or a channel registration), are dropped with a log line; the host's own reports stay. Kept messages stay held while no transport serves, even when a gate readies the connection. A message read from the transport after its close (its stdout may still be open at the child's exit), until a reconnect attempt, if one comes, closes its reader, gets the same rule before anything else reads it. - A flush admits the next transport's channel registrations ahead of what a boundary kept, unless one names a channel that an earlier removal, or an earlier registration kept in place, names; it stops at a boundary crossed during the flush. - Every answer is bound to the transport its request came on, and so are a streamed inference's chunks, through a sender the request's responder carries; an answer refused because its transport closed is logged. - A policy answer that arrives after its transport closed grants nothing, on the initial path and after a manifest change, and a request a close ended is logged as ended by the close. A manifest fetched from a transport that closed isn't installed, and a refresh queued on a transport runs only on it. - A connection is announced (mcpl:server-reconnected, module:added) only while the transport its exchange ran on still serves, checked when the announcement is made. A reconnect is announced once its attempt settles, granted or not, as before, and now also when another gate replaced its own; a server whose first connect failed is added once, by the first reconnect still connected when its attempt settles. Tests: mcpl-reconnect-events (connection: the hold, the boundary's keep and drop, the admission order, answers and chunks, between transports, lines read after a close) and mcpl-reconnect-registration (framework: real servers registering after a reconnect, during staged startup and against another server's reconnect; the marker against each kind of gate, a replaced gate, a quiesced host, a dropped transport, a runtime connect's accounting, a failed or displaced connect, the registry's onRegister, and the announcements, a refused or unanswered reconnect's included), with the fixture reregistering-mcpl-server.mjs; mcpl-manifest-changed (late manifest and policy answers, queued refreshes), and the inference router's and handler's chunk sender. mcpl-awareness-barrier's throwing runtime connects check their close ran while marked. Harnesses built without field initializers carry the exchange map. README: a connection whose policy exchange is in flight keeps both planes closed until it settles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nge marker, with no awareness gate main moved on from fe82187 by anima-research#543, anima-research#540, anima-research#402, anima-research#545, anima-research#547, anima-research#544, anima-research#557, anima-research#559, anima-research#560 and anima-research#562. Of these, anima-research#544 meets anima-research#250: an MCPL reconnect now holds control traffic as well as data until its new grant, with a per-connection marker for a policy exchange in flight, which the awareness gate's release and completion consulted. What anima-research#250 removes stays removed, and what main added keeps working beside it. Conflicts: - src/framework.ts, the gate: anima-research#544 added mcplPolicyExchanges, mcplPlanesMayOpen and settlePolicyExchange, made the gate's release and completion pass a connection by while its exchange is in flight, and added openMcplPlanesAfterExchange for a connection a newer gate had passed by. anima-research#250 removes the gate, so readyMcplPlanes, its one opener, takes the marker's check: a resume or startup's staged open passes such a connection by, and the exchange's own caller opens it once it settles. openMcplPlanesAfterExchange has no gate to follow, and goes; its callers call readyMcplPlanes for their connection. - src/framework.ts, the connect: anima-research#544's marker at registration and its transport-bound exchange stay. A staged connect runs its exchange on the current transport, as anima-research#544's does, and a runtime connect on the transport it registered, closed for good if the exchange throws (settlePolicyExchange's closeOnFailure, in place of anima-research#250's own close). module:added keeps anima-research#544's check that the registered transport still serves, made after a runtime connect's open. - src/framework.ts, the reconnect handler: anima-research#544's marker and transport checks in anima-research#250's shape: the exchange, then readyMcplPlanes for the connection, which is relisted and announced only while its transport still serves. - src/mcpl/server-connection.ts: main's file, with anima-research#250's rewrites of the comments that named the awareness barrier or gate. - README.md: anima-research#250's text, with anima-research#544's sentence about an exchange in flight moved into the delivery bullet. - test/fixtures/reregistering-mcpl-server.mjs: main's, its header naming the three test files that use it. - test/mcpl-awareness-barrier.test.ts stays deleted. anima-research#544's assertion there, that a failing runtime connect is closed while its exchange's marker still holds it, moves to mcpl-runtime-connect-failure.test.ts, where anima-research#250 keeps those tests. Outside the conflicts: - test/mcpl-reconnect-registration.test.ts, anima-research#544's: its harness stood on the gate. Its tests now stand on readyMcplPlanes, as startup's staged open and a resume reach it, and the cases only a gate had (a list change's or an awareness sync's completion, a runtime connect's awareness accounting, a newer gate replacing a connect's) go with the gate. Its first staged-startup test records startup's open of every connection through readyMcplPlanes instead of the gate's completion. - changelog.d: 0.23.0 folded modern-mcp-client.added.md, stop-reaches-every-cleanup.fixed.md and mcpl-reconnect-registration.fixed.md, so anima-research#250 no longer edits the first two. Its one-shot fragment says what changes from 0.23.0 for all three: only startup's staged open and resume() open every connection's planes, an exchange in flight still holds its connection through both, and the marks a drain hadn't sent at stop() stay queued in the ledger for the next start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The change is now +6,970 −4,363 against Written by Kestrel-2409. — Fiona-2757 |
The runtime connect's exchange comment said its close for good happens "as on main", a delivery test said what it was ported from, and the runtime connect failure tests' header pointed to main's tests of the rule beside the awareness gate, which this change removes along with those tests. Each now says what the code or test does. Comments only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Each of the three named the branch it was written on: the runtime connect's exchange comment, a delivery test's note on where it was ported from, and the header of the runtime connect failure tests, which pointed to tests beside the awareness gate that this pull request removes along with the gate. Each now says only what the code or test does. The change is now +6,968 −4,363 against Written by Kestrel-2409. — Fiona-2757 |
main 794bf97 into 0c3537c, merge base ca2f806 (1,334 commits). Ten regions in three files; git merged the rest on its own. src/api/server.ts (1): the branch-switch comment takes main's (anima-research#250: a branch switch adds or removes no Discord awareness marks) with anima-research#353's sentence on the framework refusing another move until this one's awaited steps end. test/discord-awareness-outbox.test.ts (1): main's side whole. anima-research#250 replaced the message-granular undo test this PR had given a conversationSpawnsInFlight stub field. src/framework.ts (8): - the host command's undo and switchBranch(): main dropped startDiscordAwarenessDelivery (anima-research#250); anima-research#353's lines stay (the preview waits for syncMoveReinit; switchBranch re-initializes every manager). - the surgery release's flush: main's readdDrained (anima-research#286's helper, which keeps a write it can't store in its agent's queue: R1-2's fix) with main's per-agent pendingAssistantBlocks guard. e4109c2's message:added trace for each write the release stores is kept through a new traceAs option on readdDrained (deferred-flush:surgery-release); the deferred write's id is stored by main's fromQueue, as anima-research#353's deferredWriteId did. - both sides added methods at one place: anima-research#353's reinitializeManagers family, then main's prepareMarkerBatch, refuseMarksWithoutLedger and retireAfterRestore. - rollback's and suppression's failed switch or fork: anima-research#353's moved check stays; main's retireAfterRestore replaces the discard / retireBatch(). - createConversationAgent: main's usedEphemeralAgentNames.set(name, 'conversation') with anima-research#353's conversationSpawnsInFlight.add(name). - anima-research#353's seedConversationAgentIfEmpty, then main's routeConversation, spawnConversationAgent and storeConversationGeneration (anima-research#430/anima-research#428), which replace persistConversationRouterState. Nothing calls the old name. Both spawn records are kept: anima-research#430's conversationSpawns (per channel, for routing's wait, from the routing decision on) and anima-research#353's conversationSpawnsInFlight (by name, for the move refusal's words, from after the spawn's wait for a hold). One could serve both only by changing the map's value and when a spawn counts as busy for a move. Typecheck clean on the merge (tsc 7.0.2).
…ssion and disposal cases, and R1-2's deferred write - discord-awareness-outbox: main's hostCommandFramework harness, added after this branch's base, gets conversationSpawnsInFlight, as this PR gave the harness anima-research#250 replaced. Without it the two undo tests read the move refusal's busy check on an undefined set. - reinit-after-branch-move, a suppression refused before the store moved: main now refuses a taken branch name before anything is prepared (7623cf2's known-collision check, code invalid), so the test injects the fork's own refusal and keeps exercising the moved check this PR adds to the fork's catch. - reinit-after-branch-move, a fork whose disposal fails partway after its seeding fails: anima-research#515 made evictTurnCheckpoints log a store that refuses to drop the checkpoints and go on, so that no longer fails a disposal. releaseWindow, which disposeConversationAgent runs unguarded, still can throw, so the test injects that, and the reseed pass's catch and its cleanupFailed words stay tested. - R1-2 (Anarchid's review of this PR): a message deferred during the hold for an agent whose re-initialization fails is still queued for it after the release, and stored once, in its window, after a switch to the current branch recovers it. Red-first for R1-2's test: at 0c3537c (this PR before the merge) it fails at "still queued for b after the release" (the release's flush dropped the write); on the merge it passes, since anima-research#286's readdDrained keeps a write it can't store in its agent's queue. Runs: tmp/chk/326r3/merge-tests-two-files.txt (125 of 125, both files) and tmp/chk/326r3/red-r12-on-0c3537c6.txt (1 of 1 fails as above), under ~/.local/state/ada-1017.
main 794bf97 into 0c3537c, merge base ca2f806 (1,334 commits). Ten regions in three files; git merged the rest on its own. src/api/server.ts (1): the branch-switch comment takes main's (anima-research#250: a branch switch adds or removes no Discord awareness marks) with anima-research#353's sentence on the framework refusing another move until this one's awaited steps end. test/discord-awareness-outbox.test.ts (1): main's side whole. anima-research#250 replaced the message-granular undo test this PR had given a conversationSpawnsInFlight stub field. src/framework.ts (8): - the host command's undo and switchBranch(): main dropped startDiscordAwarenessDelivery (anima-research#250); anima-research#353's lines stay (the preview waits for syncMoveReinit; switchBranch re-initializes every manager). - the surgery release's flush: main's readdDrained (anima-research#286's helper, which keeps a write it can't store in its agent's queue: R1-2's fix) with main's per-agent pendingAssistantBlocks guard. e4109c2's message:added trace for each write the release stores is kept through a new traceAs option on readdDrained (deferred-flush:surgery-release); the deferred write's id is stored by main's fromQueue, as anima-research#353's deferredWriteId did. - both sides added methods at one place: anima-research#353's reinitializeManagers family, then main's prepareMarkerBatch, refuseMarksWithoutLedger and retireAfterRestore. - rollback's and suppression's failed switch or fork: anima-research#353's moved check stays; main's retireAfterRestore replaces the discard / retireBatch(). - createConversationAgent: main's usedEphemeralAgentNames.set(name, 'conversation') with anima-research#353's conversationSpawnsInFlight.add(name). - anima-research#353's seedConversationAgentIfEmpty, then main's routeConversation, spawnConversationAgent and storeConversationGeneration (anima-research#430/anima-research#428), which replace persistConversationRouterState. Nothing calls the old name. Both spawn records are kept: anima-research#430's conversationSpawns (per channel, for routing's wait, from the routing decision on) and anima-research#353's conversationSpawnsInFlight (by name, for the move refusal's words, from after the spawn's wait for a hold). One could serve both only by changing the map's value and when a spawn counts as busy for a move. Typecheck clean on the merge (tsc 7.0.2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssion and disposal cases, and R1-2's deferred write - discord-awareness-outbox: main's hostCommandFramework harness, added after this branch's base, gets conversationSpawnsInFlight, as this PR gave the harness anima-research#250 replaced. Without it the two undo tests read the move refusal's busy check on an undefined set. - reinit-after-branch-move, a suppression refused before the store moved: main now refuses a taken branch name before anything is prepared (7623cf2's known-collision check, code invalid), so the test injects the fork's own refusal and keeps exercising the moved check this PR adds to the fork's catch. - reinit-after-branch-move, a fork whose disposal fails partway after its seeding fails: anima-research#515 made evictTurnCheckpoints log a store that refuses to drop the checkpoints and go on, so that no longer fails a disposal. releaseWindow, which disposeConversationAgent runs unguarded, still can throw, so the test injects that, and the reseed pass's catch and its cleanupFailed words stay tested. - R1-2 (Anarchid's review of this PR): a message deferred during the hold for an agent whose re-initialization fails is still queued for it after the release, and stored once, in its window, after a switch to the current branch recovers it. Red-first for R1-2's test: at 0c3537c (this PR before the merge) it fails at "still queued for b after the release" (the release's flush dropped the write); on the merge it passes, since anima-research#286's readdDrained keeps a write it can't store in its agent's queue. Runs: tmp/chk/326r3/merge-tests-two-files.txt (125 of 125, both files) and tmp/chk/326r3/red-r12-on-0c3537c6.txt (1 of 1 fails as above), under ~/.local/state/ada-1017. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ge entry for bounded jobs, the batched-wake hold What main brings since 9c6964c: 0.23.0 and 0.23.1 — #360 (hold-round speech), #250 (surgery marks opt-in), #463 (the workspace edit planner), #168 (non-conversational marker tags: a reaction, edit or deletion never retargets the publish locus, apart from an edit the coalescer marks as the agent's first view), #558 (every ephemeral agent's usage in one shared entry), #570 (the batched-wake tests hold the gate's batch), #569, #565, #566, #571, #572, #575, #576, #578 and the 0.23.x merge-backs. Conflicts in five files, resolved by composition: - src/agent.ts: the request strips the framework's token-estimate stamp (main) and then presents the agent's own turns under its name (ours). - src/framework.ts: `ephemeralCandidates` keeps this branch's record (config, spec, resume) beside main's `ephemeralAgents` WeakSet, and createEphemeralAgent adds to both; `isAddressedMessage` stays tag-only (main's second argument is unread) with main's `readsDeferredWrite` spread; the re-pin rule's doc is main's with the `speech.locus` sentence; the turn-start flush is main's `flushForStart`, which gains this branch's `releaseLandedMail` so held mail whose message lands is released. - src/mcpl/channel-registry.ts: the three Dendrite options (`latestInboundLocus`, `onToolClose`, `focusChannelResolver`) beside main's `onRouteFailure` doc; `markAccepted` takes main's `firstView` but records no publish locus — admission does, through `noteDelivered`, and the framework's three `noteDelivered` sites now apply main's marker rule (`isMarkerTagged(tags, firstView)`), so a reaction, edit or deletion is never the latest delivered inbound either; `channel_publish`'s default comment says the same. - test/batched-wake-locus.test.ts: this branch's `batchedWakeRig` keeps its shape and gains main's `holdBatches`, reading the rig's framework; every test that pairs two sends holds the batch, the two ambient-only tests included. - test/present-while-acting.test.ts: `createFramework` takes `speech`, `maxStreamTokens` and main's `proseRouting`; main's new re-pin tests (traces and typing after a re-pin, the budget restart, the inbound-only arrival) run under `LEGACY`, the latest-inbound rule the re-pin belongs to, beside this branch's default-rule test. Three seams the suite found, all at the registry's reply anchor, which main sets where it accepts a message and this branch sets where admission delivers one: - #168's marker rule now lives in `noteDelivered` (tags and the coalescer's first-view mark are passed by the framework's three delivery sites), so the registry's unit tests exercise it as the framework calls it. - `buildChannelContext` reported no `incoming` when the speech fallback was null, and no context at all when nothing was open and no outgoing channel resolved — under `speech.locus: 'delivery'` that hid the reply anchor whenever the latest inbound wasn't a speech fallback. The anchor now depends only on `takesGlobalFallback` (an ephemeral run has none), and a context with an anchor is returned whatever else it lacks. - A coalesced acceptance's anchor is committed when the acceptance is acknowledged, not when the coalescer delivers: an acceptance whose commit fails after its delivery moves no anchor, and the retry the receipt answers commits the one the first attempt parked (`parkedAnchors`, keyed by server and event id, bounded). #157's tests pinned both halves. Test adaptations beyond the conflicts: #157's two re-pin tests and outgoing-stream's re-pin test run under the legacy locus rule, as the others do; two compiled tests main deleted (`mcpl-awareness-barrier`, `prose-stream-router`) were still in `dist/test` and failing from there — removed, since tsc doesn't clean outputs whose sources are gone. Two lines git doesn't show, named by nissa-seru's review of #546: - #558 reads `config.subconscious.name` for the shared usage entry's exemptions; a forks reader has no agent of its own, so the name is read only when `reader !== 'forks'` (the union has none otherwise). - resumeAgent makes its agent with `new Agent`, so it adds the agent to `ephemeralAgents` itself: a resumed job's usage counts in the shared entry, not under its own name. Composed rig: npm test 5408 tests, 5378 pass, 0 fail, 30 skipped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The companion to anima-research/agent-framework#250 (the awareness-marks contract, Ada-1017; surface agreed in room-203 #43870/#43912/#43935, revised with Ada in room-225 #47171/#47201). Marks are the framework's: on a host with the `marks` host command, an /undo or /hide places 💤 marks only for the scope the operator chose, journals what it asked Discord to do, and returns a `markers` receipt. - Probe (hostMarksSupport): once per connection, host/command {command: 'marks', action: 'list'} — ok means the verb exists, "Unknown host command" means an older host; anything else (timeout, another error) proves neither, isn't kept, and stops the command with nothing changed. - /undo and /hide take an optional `marks` choice (none, addressed, all; omitted is none), passed to a host with the verb. The reply describes the receipt (describeMarkers): none (with why), queued N (scope, batch id — requested, not yet confirmed on Discord), not-scheduled, unresolved (may still be delivered later), plus unmarked / not-removed counts. Nothing is claimed beyond the receipt. - On a host without the verb: - /undo is refused whatever the option. Its undo places marks that follow branches (re-applied on every switch, no frozen audience, no cancel), so no option here can authorize them as the one-shot act it names; the web UI refuses the same way (Ada's #47171, accepted #47201). - /hide is sent without a marks field, and this server reacts 💤 itself only for `all` (a one-shot act whose choice and application coincide), and only when the framework returned no markers. `addressed` is refused (this server can't tell which hidden messages addressed the agent). With no choice, nothing is marked — /hide no longer reacts on every hidden message by default. - /marks action [target] (new admin command): list, cancel, retract, release through host/command `marks`, answered ephemerally; renderAwareness shows batches (status, scope, refs, adds/removals by status, unresolved, held) and retracts, capped at 12 entries. A missing target is refused before asking the host; the host's refusal shows with its code (agent-busy while any agent sharing the store is mid-turn). New module src/awareness-marks.ts holds the option, the choice reader, the receipt and journal types and their rendering. Tests (test/awareness-marks.test.ts, host and Discord stubbed): /undo refused on an older host for every option with nothing undone; an unproven probe stops it and is asked again; no marks sent when none chosen, one probe per connection; a chosen scope passed and its queued receipt described; /hide on a host with the verb passes the choice and never reacts; on an older host it reacts itself only for all (no marks field sent), refuses addressed, marks nothing by default; /marks lists ephemerally, passes action and target, needs a target, shows a refusal's code, explains an older host; every receipt status rendered; a long journal capped; registration offers the choice on /undo and /hide and registers /marks. server.test.ts's /undo fixture gained getString. 305/305 (main 293).
Everything filed under Unreleased since 0.3.0, from the pull requests merged since: #68 (entries restored under Unreleased), #105, #107, #108, #109, #110, #111 and #112. Minor bump: /undo and /hide now mark nothing unless marks is chosen, and /undo needs a host whose agent framework has the marks command (anima-research/agent-framework#250, in 0.24.0 and later); see the upgrade notes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This PR makes Discord awareness marks the operator's explicit, one-shot choice, delivers them beside MCPL rather than in its path, and gives operators list, cancel and retract. It's a breaking change: a host or command interface that should keep offering marks has to pass the operator's choice. The awareness ledger becomes a table of reactions, rewritten in place as version 3, which 0.22.x doesn't read.
Problem
Field incident (another household's resident, 2026-10-07): one web-UI rollback removed 918 Discord messages from a resident's context, and the framework queued a 💤 reaction on every one of them. Most were other people's ambient messages in a busy channel. The marks weren't the operator's choice, nothing showed what would be marked before it happened, and nothing could stop them except stopping the host. While the serial reaction drain ran, every MCPL data plane was held, so the resident was deaf. Marks also followed the branch: switching back to the source removed them, and switching forward added them again.
#246, released in 0.22.1, stopped surgeries from waiting on the drain by moving it behind a data-plane gate. This PR changes the marks contract and removes that gate.
The contract
marks: 'none' | { scope: 'addressed' | 'all', refs? }, and thehost/commandundo by messages andagent-framework-recover --markstakenone,addressedorall. Each marks nothing unless the operator chooses.addressedcovers messages taggedchat:addressed(mentions, replies to the bot, DMs).previewSurgeryMarks()shows each scope's counts, channels and refs, and passing a scope'srefsback binds the choice to exactly those messages, so a message that arrives later is removed but never marked.hideand an undo by turns place no marks, and refuse any other choice. A framework with no ledger refuses any choice butnone.resume()or startup's open of every connection, as onmain. A route that's down keeps its work queued. A mark whose request failed in a way a retry could change is sent again by the next delivery pass, and a trigger that meets a pass already running has that pass try it once more before it ends. Nothing more is sent for a mark after three unconfirmed attempts at it, or after a refusal no retry can change, and a pass that gives up raises one ops alert. Oncestop()has begun, no drain sends another mark, and the next start sends what's still owed.host/commandmarksverb. A cancel and a retract are each recorded in the operator log, andstop()waits for one under way:listDiscordAwareness()lists batches and retract requests, each with its reactions counted by state and how many may still land;cancelDiscordAwareness(batch | retractRequest)stops what it hasn't sent, and never removes or undoes anything;retractDiscordAwareness(batch | 'all')queues removal of this bot's reaction from every selected message, whatever was answered before.<store>/recovery/discord-awareness-outbox.jsonordiscordAwarenessOutboxPath, now version 3. A version 2 file is rewritten in place by its first write, at a host's start or an offline recovery. The marks 0.22.x still had to add or remove are never sent, since nobody chose them. The rewrite names their batches on stderr, and at a host's start in adiscord-awareness-import-unsentops alert too. 0.22.x reads only versions 1 and 2, and its start stops on a version 3 file: to go back to 0.22.x, move the file aside first.What each part is for
The purpose is that marks reach Discord only when an operator chooses them, once, and never hold MCPL or a turn. Sizes are against
main; forframework.tsthey're added lines by method, doc comments included, so they're approximate.The choice, the preview and the receipts (contract 1) answer the incident's cause: marks nobody chose, on messages nobody saw. They're about 320 added lines in
framework.ts(planning, preview, prepare, activation, the receipt and the source's restore on failure), the ledger module's selection, andoffline-branch.ts(+189 −55). The preview andrefsare what make a choice an informed one: without them, a scope covers messages the operator never saw.One-shot (2) is deletion: the reconciliation a branch switch ran, and the delivery it started.
No gate (3) is deletion too: the gate in
framework.ts, its test file and fixture (1,570 lines), and its cases in three other test files. Whatmainkeeps beside it stays: a connection's planes stay closed while its §5.3 exchange is in flight, andreadyMcplPlanes, now the one opener, passes such a connection by until the exchange's own caller opens it.Background delivery (3) is about 215 lines in
framework.ts: one drain pass per server at a time, the operator's sync, and whatstop()waits for. The bound and its alert are what keep a server that never answers from being asked forever.The controls (4) are about 145 lines in
framework.ts, with themarksverb, and the ledger's cancel, retract and list. Without them, as in the incident, only stopping the host stops marks.Interrupted work (7) is about 110 lines in
framework.tsand the ledger's start recovery. A suppression's resume ismain's.The ledger (8) is 1,622 lines, 509 of them comments, against
main's 478. A table holds what the contract needs: what each reaction should be, what's owed, and what was answered. Its rules:It's a file outside the store's records, as on
main, so a rollback or a branch deletion never touches it. Each write goes to a temporary file, synced, renamed over the ledger, and then the ledger's directory and that directory's parent are synced, by the rule the framework's own recovery files follow: a write that fails leaves the ledger as it was, and a directory that can't be synced after the rename raises therecovery-syncops alert while the write stands. A write that activates, completes or retires a batch syncs the store first, so it never outlives the body change it certifies. The document stays in memory with its one writer, and acts run one at a time, each written before the next starts. An act that throws has left the file as it was, and what it changed in memory is dropped with it.Weighed and left out:
hideand an undo by turns: neither made marks of its own onmain, and discord-mcpl 0.3.0's/hidereacts to its hidden messages itself.agent-framework-recover --awareness) and the CLI's own operator log: an operator who wants queued marks stopped can start the host and cancel them, and a few may go out first.mainnow, as fix(mcpl): a reconnect holds control traffic as well as data until its new grant, whatever gate completes meanwhile #544.release(), which would have sent work 0.22.x had queued before marks were anyone's choice, andMcplRequestError'scode, which nothing read.Size: this head is +6,968 −4,363 against
main: source +2,877 −1,352, tests +3,769 −2,985, and the README and changelog +322 −26. Before these cuts and the table,7ea6512cwas +10,739 −3,966 against themainit had merged.Costs
writeLedgerFile's writes but for its sync of the directory's parent, which runs off the loop too. Its table gives one entry in 50 an error text, a field this head no longer keeps, so its file is a little larger than this head's would be. The incident's rollback would have made 918 entries.storePathnordiscordAwarenessOutboxPathcan't mark.The probe
Run as
bun ledger-async-probe.mjs DIRandnode ledger-async-probe.mjs DIR.Tests
discord-awareness-outbox.test.tspins the ledger: each rule under "What each part is for", the version 2 rewrite and its notice, the refusals of version 1, of other versions and of a file not in version 3's shape, failed writes and unsynced directories, and what a cancel's receipt counts. Its framework cases pin the drain: how it reads each outcome, a thrown refusal no retry can change included, with the server's words redacted; one pass per server at a time; a request made owed just after a pass found nothing, at four microtask depths; a trigger that joined a pass whose route went away; and a request written ahead asstop()begins. They also pin cancel and retract as operator operations, and a framework with no ledger.surgery-marks-receipt.test.ts: the choice, the preview andrefs, the receipts, a failed surgery's batch, a retract whose ledger directory can't be synced after its write, and a start's handling of interrupted surgeries and of a version 2 ledger.discord-awareness-delivery.test.ts: delivery in the background, the bound and its alert, the deadline withrequestTimeoutMs: 0, a route that's down, an operator's sync that meets a running pass, and an inbound event handled in the turn it arrives.offline-recovery-branch.test.ts: the offline recovery's choice, its receipt, a failed offline suppression, and its rewrite of a version 2 ledger.stop-ends-awareness-drains.test.tsandstop-with-host-operations.test.ts: whatstop()waits for and what it refuses.placed-reaction-baseline.test.ts: only a connect or a restart that derives the protective default reads the ledger.mcpl-runtime-connect-failure.test.ts: a runtime connect whose policy registration throws closes its connection for good, while its exchange still holds its planes. These tests come from the gate's test file.mcpl-reconnect-registration.test.ts:main's tests of the exchange's hold, which stood on the gate, now stand onreadyMcplPlanes: a reconnect's and a runtime connect's planes stay closed through an open of every connection (readyMcplPlanes(), which startup's staged open andresume()call) until the exchange settles, on a quiesced host too. The cases that only a gate had (a list change's or an awareness sync's completion, a runtime connect's awareness accounting) go with it.The gate's tests went with it:
mcpl-awareness-barrier.test.tsand its fixture, and its cases instop-with-work-in-flight.test.ts,mcpl-request-handler-errors.test.tsandmcp-server-ownership.test.ts.Runs at this head: the full suite on Node 20 and on Node 24, 245 files and 4,811 tests on each, none failing and 27 skipped (
main's tests inworkspace-path-spelling.test.tsthat need a directory that folds names, as macOS's does by default), and CI's unforced step on each. A removal run took each of the ledger's rules, and the framework's parts of the ledger, out of the compiled build one at a time, 60 in all, and ran the test files aimed at each: every removal failed at least one test. An internal review read the whole change atbb04f5d5, and this head carries its fixes.Not verified
Not covered
Merge order and companions
485a3c7, from October 7.marksoption for/undoand/hide, a/marksadmin command, and a probe for hosts with this contract. At0bba5afit checks the list, cancel and retract replies of an earlier head of this PR, which carriedadds,removalsandunresolvedAttempts, andunresolvedAddAttemptsin a retract's receipt (src/awareness-marks.ts, lines 338 to 375). This head's replies carryreactionsandmayStillLandinstead, so those checks refuse them. Its texts for anunresolvedreceipt (lines 46, 92 and 119) and for a queued receipt'serror(lines 87 and 115) are for cases this head no longer has: a surgery's receipt isnone,queuedornot-scheduled, and a queued one carries no error.6d10a51it callsreleaseDiscordAwareness, which this head doesn't have, and takes cancel's and retract's receipts as returned values, where this head returns promises of them (src/modules/web-ui-module.ts, lines 329 to 332). Its check for anunresolvedreceipt (line 1791) is for a status this head no longer has.Implemented by Ada-1017 and independently reviewed by June-1016 (the Rookery's corvids), using Claude Opus 5.5 and OpenAI Astra respectively. The Greptile follow-up was implemented by Ansel-1606 and independently reviewed by Mira-1605, likewise. The fixes for slimepriestess's notes were implemented by Nigel-1885 and independently reviewed by Cordelia-1884, both using Claude Opus 5.5. The review round was implemented by Ada-1017 and independently reviewed by Petra-1012, both using Claude Opus 5.5. The second and third review rounds and both merges with main were implemented by Kestrel-2409, using Claude Opus 5.5, and independently reviewed by Phoebe-2419, also using Claude Opus 5.5. Every later round, and the third through ninth merges with main, were implemented by Kestrel-2409; Phoebe-2419 independently reviewed the first of them, checked this PR's first internal review of each part, and reviewed everything from
f6ea356cto15a822b6; Myrtle-2415, also using Claude Opus 5.5, independently reviewed everything from there toed40f2d5. Naomi-2422, also using Claude Opus 5.5, independently reviewed the merge ofe6703634from #459's side, and16dae9a. The cut round aftered40f2d5comes from Ogden-2688's weighing of the journal; Ogden-2688 also uses Claude Opus 5.5. Kestrel-2409 implemented it, and Ogden-2688 independently reviewed it. Myrtle-2415 independently reviewed the merges of5f5444d6,d9bd6dc9and9e3e407c,d337d42andbf4f4e5. This head rebuilds the pull request after an internal review of each part by Esther-2584, also using Claude Opus 5.5, with Ada-1017's calls on what stays: Kestrel-2409 implemented it and wrote this description, and Rupert-2634, also using Claude Opus 5.5, independently reviewed it. Kestrel-2409 merged4fd2bfb9into it, and Crispin-2713, also using Claude Opus 5.5, independently reviewed that merge. All of it went through the Rookery's tooling. Daniel-2612 checked and posted this description, and Fiona-2757 checked and set its changes for the merge of4fd2bfb9.