Skip to content

feat(awareness): make Discord awareness marks an explicit, one-shot choice that never gates MCPL - #250

Merged
nissa-seru merged 98 commits into
anima-research:mainfrom
nissa-seru:feat/surgery-marks-opt-in
Oct 19, 2026
Merged

nissa-seru merged 98 commits into
anima-research:mainfrom
nissa-seru:feat/surgery-marks-opt-in

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

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

  1. Opt-in, default none. A rollback, a suppression and an offline recovery take marks: 'none' | { scope: 'addressed' | 'all', refs? }, and the host/command undo by messages and agent-framework-recover --marks take none, addressed or all. Each marks nothing unless the operator chooses. addressed covers messages tagged chat:addressed (mentions, replies to the bot, DMs). previewSurgeryMarks() shows each scope's counts, channels and refs, and passing a scope's refs back binds the choice to exactly those messages, so a message that arrives later is removed but never marked. hide and an undo by turns place no marks, and refuse any other choice. A framework with no ledger refuses any choice but none.
  2. One-shot. Moving between branches sends nothing. A switch, an undo, a redo or a restart carries out only choices already made.
  3. Beside MCPL, never in its path. The awareness gate on MCPL data planes is gone. A connection's planes open once its §5.3 policy exchange has settled, and marks are delivered in the background. A connection whose exchange is in flight stays closed through a resume() or startup's open of every connection, as on main. 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. Once stop() has begun, no drain sends another mark, and the next start sends what's still owed.
  4. Operator controls, as framework methods and as the host/command marks verb. A cancel and a retract are each recorded in the operator log, and stop() 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.
  5. The later act wins. Between opposite wishes for one reaction, the later act wins. A surgery's marks carry the moment of its choice, even when they're activated later, so a retract made in between prevails, and cancelling that retract revives nothing.
  6. Receipts say what's known. A request written ahead and never answered, one left on the wire by a host that stopped included, counts as one that may still land, whatever is answered later. Cancel and retract receipts say how many may, and a cancel's receipt counts the reactions its act still wants.
  7. Interrupted work. A start never activates a surgery's marks. A batch an interrupted surgery left prepared is retired at the next start, none of its marks sent. One that had marks to apply raises an ops alert that names the surgery, its agent and its branches, and the redo that applies the marks. An interrupted suppression's removals are still finished at the first start on its branch.
  8. The ledger has one entry per reaction (route, channel, message and emoji): what the acts that still stand want of it, whether Discord is owed a request for it, and what Discord last answered. It's the same JSON file as before, <store>/recovery/discord-awareness-outbox.json or discordAwarenessOutboxPath, 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 a discord-awareness-import-unsent ops 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; for framework.ts they'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, and offline-branch.ts (+189 −55). The preview and refs are 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. What main keeps beside it stays: a connection's planes stay closed while its §5.3 exchange is in flight, and readyMcplPlanes, 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 what stop() 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 the marks verb, 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.ts and the ledger's start recovery. A suppression's resume is main'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:

    • a choice and a retract each take the next moment of the ledger's counter;
    • an activation owes an add only where no retract later than the batch's choice reached the reaction, cancelled or not;
    • a request is written ahead before it leaves, and a reaction is asked again only once its answer is written; a request of the server still marked on the wire when its next request is claimed, one whose answer couldn't be written, is recorded as never answered first;
    • an answer ends what's owed only for the act its request served;
    • a request never answered stays one that may land;
    • three unconfirmed attempts, or a refusal for good, end what's owed;
    • a cancel takes its act out of the reaction's wishers, and a reaction nobody wants is owed nothing.

    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 the recovery-sync ops 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:

  • Reading and writing the file synchronously at each act, with the file's and the directory's syncs: at 10,000 entries, each act held the event loop for a median of 29 ms on Bun and 47 to 49 ms on Node 24.
  • Serializing only what changed: a missed invalidation would silently write a stale table, which is worse than the time it saves.
  • Failing a write whose directory couldn't be synced after its rename: the act would report a failure for a change the file holds, and a retract reported that way wouldn't start its delivery.
  • A ledger in memory for a framework with no path: it would make the write-ahead promise false, so such a framework refuses a choice instead.
  • A journal of every request and attempt, in the Chronicle store, with its replay and its import of the JSON ledger: per-request history is more than the contract needs. Receipts count the reactions on which a request may land, not the requests themselves.
  • Marks for hide and an undo by turns: neither made marks of its own on main, and discord-mcpl 0.3.0's /hide reacts to its hidden messages itself.
  • Offline controls (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.
  • MCPL reconnect fixes, and refusing a pending suppression's branch name, and a switch or a redo onto it: the one-shot choice needs neither. The reconnect fixes are on main now, 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, and McplRequestError's code, 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, 7ea6512c was +10,739 −3,966 against the main it had merged.

Costs

  • Writes. Every act that changes the ledger rewrites the whole file. At 10,000 entries (a 3.8 MB file), serializing it held the event loop for a median of 4.3 to 5.4 ms on Bun 1.4.3 (p99 10.8 to 17.1 ms) and 15.6 to 16.5 ms on Node 24.19 (p99 24 ms), over 200 writes a run. The temporary file's write and sync, the rename and the directories' syncs run off the loop. One host, at a load average near 1.2. The probe, below, makes 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.
  • Growth. Nothing prunes the table: it keeps an entry for every reaction an act has reached, so that a retract of everything reaches each one.
  • Going back. A host downgraded to 0.22.x after the rewrite stops at its start until the file is moved aside, and then starts with no marks history.
  • No ledger path. A framework created with neither storePath nor discordAwarenessOutboxPath can't mark.
The probe

Run as bun ledger-async-probe.mjs DIR and node ledger-async-probe.mjs DIR.

// The version 3 ledger's per-request writes with the document kept in
// memory between writes (one writer), serialized on the event loop, and the
// file I/O through the async API: temp file write, its fsync, rename, the
// directory's fsync. A 1 ms interval timer runs throughout and records how
// late each tick fires, which is how long the loop was held: the stall the
// host's other work would see.
// usage: bun|node ledger-async-probe.mjs DIR [ENTRIES=10000] [REQUESTS=100] [INDENT=0|2]
import { mkdirSync, rmSync } from 'node:fs';
import { open, rename } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { randomUUID } from 'node:crypto';
import os from 'node:os';

const dir = process.argv[2];
const entriesN = Number(process.argv[3] ?? 10000);
const requests = Number(process.argv[4] ?? 100);
const indent = Number(process.argv[5] ?? 0);
if (!dir) throw new Error('usage: DIR [ENTRIES] [REQUESTS] [INDENT]');
mkdirSync(dir, { recursive: true });
const path = join(dir, 'discord-awareness-outbox.json');
const now = () => Number(process.hrtime.bigint()) / 1e6;
const snowflake = (i) => String(1200000000000000000n + BigInt(i) * 7919n);

// 10,000 reactions in batches of 200, each wanted present and answered
// present, one in 50 with a recorded error.
const perBatch = 200;
let moment = 0;
const batches = [];
const entries = [];
for (let b = 0; b * perBatch < entriesN; b++) {
  const id = randomUUID();
  const chosen = ++moment;
  const refs = [];
  for (let k = 0; k < perBatch && b * perBatch + k < entriesN; k++) {
    const i = b * perBatch + k;
    const ref = { serverId: 'discord', channelId: `discord:${snowflake(b)}:${snowflake(b + 7)}`, messageId: snowflake(i + 100000) };
    refs.push(ref);
    const entry = { ...ref, emoji: '💤', wanted: 'present', wantedBy: [id], due: null, attempts: 0, confirmed: 'present' };
    if (i % 50 === 0) {
      entry.lastError = `MCPL server "discord" returned error for tools/call: [-32000] Discord API error 50001: Missing Access ${'x'.repeat(120)}`;
    }
    entries.push(entry);
  }
  batches.push({
    id, status: 'active', agentName: 'resident', sourceBranch: 'main',
    targetBranch: `rollback/resident/${1760000000000 + b}`, emoji: '💤', createdAt: 1760000000000 + b,
    chosen, scope: 'addressed', refs, unmarked: 3,
  });
  ++moment;
}
const doc = { version: 3, moment, batches, retracts: [], entries };

async function persist() {
  const t0 = now();
  const json = `${JSON.stringify(doc, null, indent || undefined)}\n`;
  const t1 = now();
  const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
  const file = await open(temporary, 'w', 0o600);
  try {
    await file.writeFile(json);
    await file.sync();
  } finally {
    await file.close();
  }
  await rename(temporary, path);
  const directory = await open(dirname(path), 'r');
  try {
    await directory.sync();
  } finally {
    await directory.close();
  }
  return { bytes: Buffer.byteLength(json), stringify: t1 - t0, elapsed: now() - t0 };
}

// The loop monitor: a 1 ms timer's lateness, per tick.
const lateness = [];
let expected = now() + 1;
const timer = setInterval(() => {
  const t = now();
  lateness.push(Math.max(0, t - expected));
  expected = t + 1;
}, 1);

await persist(); // the starting file
lateness.length = 0;
const steps = [];
const runStart = now();
for (let r = 0; r < requests; r++) {
  const entry = entries[(r * 7919) % entries.length];
  for (const kind of ['claim', 'outcome']) {
    doc.moment += 1;
    if (kind === 'claim') entry.sending = { action: 'add', due: doc.moment };
    else { delete entry.sending; entry.confirmed = 'present'; entry.due = null; entry.attempts = 0; }
    steps.push({ kind, ...(await persist()) });
  }
}
const runMs = now() - runStart;
clearInterval(timer);
rmSync(path, { force: true });

const quantile = (values, q) => {
  const sorted = [...values].sort((a, b) => a - b);
  return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
};
const fmt = (v) => v.toFixed(2);
console.log(JSON.stringify({
  runtime: typeof Bun !== 'undefined' ? `bun ${Bun.version}` : `node ${process.version}`,
  host: os.hostname(), loadavg: os.loadavg().map((v) => v.toFixed(2)),
  entries: entriesN, requests, writes: steps.length, indent, bytes: steps[0].bytes, runSeconds: (runMs / 1000).toFixed(1),
}));
for (const [name, values] of [
  ['stringify (on the loop)', steps.map((s) => s.stringify)],
  ['write elapsed (awaited)', steps.map((s) => s.elapsed)],
  ['loop lateness per tick', lateness],
]) {
  console.log(`${name.padEnd(24)} median ${fmt(quantile(values, 0.5)).padStart(7)}  p95 ${fmt(quantile(values, 0.95)).padStart(7)}  p99 ${fmt(quantile(values, 0.99)).padStart(7)}  max ${fmt(Math.max(...values)).padStart(7)} ms  (n=${values.length})`);
}
console.log(`ticks held over 10 ms: ${lateness.filter((v) => v > 10).length}; over 20 ms: ${lateness.filter((v) => v > 20).length}`);

Tests

  • discord-awareness-outbox.test.ts pins 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 as stop() 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 and refs, 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 with requestTimeoutMs: 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.ts and stop-with-host-operations.test.ts: what stop() 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 on readyMcplPlanes: a reconnect's and a runtime connect's planes stay closed through an open of every connection (readyMcplPlanes(), which startup's staged open and resume() 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.ts and its fixture, and its cases in stop-with-work-in-flight.test.ts, mcpl-request-handler-errors.test.ts and mcp-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 in workspace-path-spelling.test.ts that 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 at bb04f5d5, and this head carries its fixes.

Not verified

  • No live Discord or provider. The fake server paces reactions in milliseconds.
  • The ledger's failed writes are produced through directory permissions: a write that fails before its rename, one whose rename lands and whose directory's or its parent's sync fails, an answer that can't be written, and a file that can't be read back. No test ends a process in the middle of a write, and none produces the directory sync's win32, EINVAL or EBADF paths.
  • The write timings come from one host.

Not covered

  • An offline recovery's rewrite notice is its stderr line. When the offline CLI rewrites a version 2 ledger, its stderr names the batches whose unsent marks were dropped, and the host's next start raises no alert for them, since the file is already version 3.

Merge order and companions


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 f6ea356c to 15a822b6; Myrtle-2415, also using Claude Opus 5.5, independently reviewed everything from there to ed40f2d5. Naomi-2422, also using Claude Opus 5.5, independently reviewed the merge of e6703634 from #459's side, and 16dae9a. The cut round after ed40f2d5 comes 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 of 5f5444d6, d9bd6dc9 and 9e3e407c, d337d42 and bf4f4e5. 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 merged 4fd2bfb9 into 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 of 4fd2bfb9.

Ada-1017 added 14 commits October 7, 2026 08:27
…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.
@greptile-apps

greptile-apps Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 3/5 Tier: apex

[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

  1. P1 Offline recovery marks too much ▶
  2. P1 Malformed imports skip unfinished removals ▶
  3. P2 Sync returns before delivery ends ▶
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.

Summary

Makes 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.

  • Surgery marks follow an explicit choice, not branch movement.
  • A durable journal keeps mark choices and delivery history across restarts.
  • Mark delivery runs beside MCPL traffic and stops claiming work at shutdown.
  • Operators can cancel pending marks or retract reactions they chose.
  • Reconnected MCPL servers wait for their new policy before traffic opens.
  • MCPL requests report whether they were sent, refused, or unanswered.

Reviews (2) · Last reviewed commit: "Merge main (3adbdb86) into feat/surgery-..." · Reviewed by Greptile

Comment thread src/framework.ts Outdated
Comment thread src/framework.ts Outdated
Comment thread src/framework.ts Outdated
Comment thread src/recovery/recover-cli.ts Outdated
Comment thread src/framework.ts Outdated
Comment thread src/recovery/discord-awareness-outbox.ts Outdated
…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.
Ansel-1606 added 3 commits October 7, 2026 16:37
…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.
Ansel-1606 added 3 commits October 7, 2026 17:36
…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).
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 7, 2026
…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>
Kestrel-2409 and others added 2 commits October 18, 2026 21:49
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
@nissa-seru
nissa-seru force-pushed the feat/surgery-marks-opt-in branch 2 times, most recently from 37c1acb to 85a714b Compare October 19, 2026 02:40
@nissa-seru

Copy link
Copy Markdown
Contributor Author

7ea6512..85a714b rebuilds this pull request after an internal review of each part against what the one-shot choice needs. For a read from 3fb0415, the range is 3fb0415..85a714b. 85a714b replaces 61fc2d9 and 37c1acb, pushed minutes earlier, with the same trees and reworded commit messages.

What came of it:

The change is +6,975 −4,059 against main, where 7ea6512 was +10,739 −3,966 against the main it had merged. The description is rewritten for this head and lists its runs.

Written by Kestrel-2409.

— Daniel-2612

nissa-seru pushed a commit that referenced this pull request Oct 19, 2026
…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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

85a714b..c4b472c merges main at 4fd2bfb. For a read from 3fb0415, the range is 3fb0415..c4b472c.

  • fix(mcpl): a reconnect holds control traffic as well as data until its new grant, whatever gate completes meanwhile #544's hold stays, without the awareness gate it was built into. A connection whose §5.3 policy exchange is in flight stays closed, both planes, through startup's open of every connection and a resume(). Its own connect or reconnect opens it once the exchange settles. #​544's tests of the hold now run against that opener, and the cases only a gate had are removed with the gate.
  • 0.23.0 folded two changelog fragments this pull request had edited, so it no longer edits them. Its own fragment says what changes from 0.23.0.

The change is now +6,970 −4,363 against main. The description lists this head's runs.

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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

c4b472c..915ad4b changes three comments, and no code. For a read from 3fb0415, the range is 3fb0415..915ad4b.

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 main.

Written by Kestrel-2409.

— Fiona-2757

@nissa-seru
nissa-seru merged commit e0f40cd into anima-research:main Oct 19, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
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).
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
tel-0s added a commit that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to anima-research/discord-mcpl that referenced this pull request Oct 21, 2026
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).
antra-tess added a commit to anima-research/discord-mcpl that referenced this pull request Oct 22, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants