Skip to content

test(mcpl): vector 34c bounds its store syncs, not its time - #431

Merged
nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/vector-34c-sync-count
Oct 18, 2026
Merged

nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/vector-34c-sync-count

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

TL;DR: vector 34c now checks how many store syncs its 300 coalesced messages make, at least one and at most one a message, instead of how long they take. The time follows the disk and the host's load. On a loaded Linux host the test failed at 34.1 s with nothing wrong in the code, and with each store sync slowed by 120 ms it failed in both runs, since 300 syncs at 120 ms take 36 s, past the 30 s bound. The count is the cost #196's review was about, and a regression to more than one sync a message on the path this test drives fails it on any machine. Test-only; no changelog fragment.

We won't merge this before 06:00 UTC on October 18, or before our pre-merge checks pass on its merge with the then-current main, and a line here asking us to wait holds it.

Problem

"#196 finding 2 / vector 34c: a busy channel never hits a subject cap" sends 300 coalesced channels/incoming messages one at a time, then asserts that they took under 30 s. Each acceptance awaits a durable commit, one store.sync(), so the time is mostly fsync time and CPU contention. On Linux with Node 24.22.0, at load averages of 57 to 78, main's version took 10.8 s to 34.1 s over six runs, three of them beside six node -e "for(;;){}" loops, and failed once, at 34.1 s. With every store.sync() slowed by 120 ms, through a preload that busy-waits inside it (below), it failed both times, at 44.2 s and 46.7 s.

The bound came from #196's review, which measured 85 ms a message on this path because its journal synced the store for each of three operations per message. That cost is a count of syncs per message, and the wall-clock bound couldn't tell it apart from a slow disk.

Changes

test/mcpl-coalescing.test.ts, vector 34c only, in two commits: 10314c5d replaces the time bound with the count, and f446e8d0 answers an internal review of it.

  • The test counts every store.sync() call during the 300 sends, and asserts at least one and at most 300. The ceiling lets commits shared between acceptances pass. The floor fails a run in which all the acceptances stop committing, or the count stops seeing their commits. In every run of the test as committed, below, the count was exactly 300.
  • Its fixture sets syncIntervalMs: 0. start() arms the periodic store sync, and this fixture never calls it, so the option changes nothing today. It keeps that sync off if a later fixture calls start().
  • It reports the time beside the count. It no longer bounds per-message cost that isn't a store sync. Each send still has the fixture's reply deadline, which turns a reply that never comes into a failure, since CI runs without a test timeout.
  • Its functional checks are unchanged: every message is accepted, nothing wakes the agent before the mention, and the one turn after the mention reads both the first message and the mention.

What each part is for

The test targets the repeated store-sync work reported in #196's review. It replaces the elapsed-time bound with bounds on the number of store.sync() calls.

  • The count during the 300 sends. A wrapper counts calls, and the test snapshots the count before draining the framework or sending the mention. This observes the selected operation directly. A looser wall-clock bound would still mix extra sync calls with host and disk speed; removing the bound would drop the guard.
  • The ceiling of 300 calls for 300 sends. A mutation that calls host.commit twice per acceptance reports 600 sync calls and fails. Unlike an exact count of 300, the ceiling permits fewer calls so a later implementation sharing commits between acceptances can satisfy this count check.
  • The floor of at least one call. One assertion rejects an interval with zero observed calls, whether the acceptance path stopped calling sync() or the counter stopped seeing those calls.
  • syncIntervalMs: 0. It changes nothing in these runs because the fixture never calls start(). It explicitly selects no periodic store-sync timer if a later fixture does call start(), keeping that source of calls out of the intended measurement.
  • The time in the console line. Kept as information for whoever investigates a slow run. Nothing asserts on it.

Cut: the 30 s assertion over the 300 sends. The new count bounds neither elapsed time nor work other than store.sync() calls.

Tests

At f446e8d0, on Linux with Node 24.22.0:

  • Alone, 2 runs at load averages of 27 to 31: 5.3 s and 6.8 s, 300 store syncs each, both passing.
  • The coalescing file: 67 of 67 on Node 24.22.0 and on Node 20.20.2, with typecheck and build clean.
  • With each acceptance's commit removed (the await this.host.commit?.() in PushCoalescer.acknowledge()), it fails at the floor: "300 coalesced messages made no store sync: the acceptances didn't commit, or the count can't see their commits".
  • With each acceptance committing twice, it fails at the ceiling: "300 coalesced messages made 600 store syncs, more than one a message".

At 10314c5d, whose test has the same count and ceiling without the floor:

  • Without added load, 3 runs at load averages of 74 to 77: 14.6 s to 27.3 s, 300 store syncs each, all passing.
  • With each store.sync() slowed by 120 ms, 2 runs: 60.1 s and 61.1 s, 300 store syncs, both passing. main's version failed both of its runs on time, at 44.2 s and 46.7 s at load averages of 40 to 43.
  • Beside six busy loops, 6 runs at load averages of 64 to 86: 18.3 s to 45.9 s, 300 store syncs each, all passing. Two of them, at 30.9 s and 45.9 s, took longer than the old bound allowed.
  • Full check at 10314c5d, with main (14e25dc0) run the same way beside it, on Linux at load averages between about 15 and 45. Typecheck and build are clean. Each file on its own, without --test-force-exit, passes 2378 of 2378 tests on Node 24.22.0 and on Node 20.20.2, the same count as main. npm test, as CI runs it on Node 24.22.0, passed 2352 of 2352, and the same forced run with TAP passed 2334 of 2334 on Node 24 and 2378 of 2378 on Node 20. Forced runs report fewer tests because --test-force-exit can end a file before all its tests report (npm test's --test-force-exit can end a test file before its tests report, so a short run still passes #294). Our run of CI's unforced step passed 105 of 105 on both Nodes. In main's run of the same commands, vector 34c failed on time on Node 20, beside other tests that fail under load.

The slowed syncs come from Nigel-2654's preload, which wraps each framework's store.sync() and busy-waits F61_SYNC_DELAY_MS before calling it, since a slow fsync holds the event loop the same way (F61_SYNC_DELAY_MS=120 node --import ./f61-sync-delay.mjs --test-name-pattern='vector 34c' dist/test/mcpl-coalescing.test.js):

f61-sync-delay.mjs
// F61 probe preload: each durable commit (the framework store's sync) takes
// F61_SYNC_DELAY_MS longer, as on a loaded disk, and is counted per framework.
// The delay is a synchronous wait because store.sync() is synchronous: a slow
// fsync holds the event loop the same way.
// Use: node --import <this file> <test file>, from the agent-framework root.
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';

const delayMs = Number(process.env.F61_SYNC_DELAY_MS ?? 0);
// F61_HANG_COMMITS=1: the coalescer's durable commit never resolves, so the
// first coalesced admission waits forever while the event loop stays free:
// a host that never answers, with timers still able to fire.
const hangCommits = process.env.F61_HANG_COMMITS === '1';
const { AgentFramework } = await import(pathToFileURL(resolve(process.cwd(), 'dist/src/framework.js')).href);
const create = AgentFramework.create;
let n = 0;
globalThis.__f61 = { delayMs, frameworks: [], current: null };
AgentFramework.create = async function (config) {
  const fw = await create.call(this, config);
  const store = fw.store;
  const sync = store.sync.bind(store);
  const rec = { id: ++n, syncs: 0, syncMs: 0 };
  globalThis.__f61.frameworks.push(rec);
  globalThis.__f61.current = rec;
  store.sync = (...args) => {
    rec.syncs++;
    const start = performance.now();
    if (delayMs > 0) {
      const end = start + delayMs;
      while (performance.now() < end) { /* held, as a synchronous fsync holds it */ }
    }
    const out = sync(...args);
    rec.syncMs += performance.now() - start;
    return out;
  };
  if (hangCommits) fw.commitCoalescingDurable = () => new Promise(() => {});
  const stop = fw.stop.bind(fw);
  fw.stop = async (...args) => {
    console.error(`[f61-probe] framework ${rec.id} stopping: ${rec.syncs} store.sync calls so far, ${rec.syncMs.toFixed(0)} ms in them`);
    return stop(...args);
  };
  return fw;
};
console.error(`[f61-probe] each store.sync() delayed ${delayMs} ms${hangCommits ? '; coalescing commits never resolve' : ''}`);

Not verified


Built by Meg-2646 (Claude Opus 5.5), with design calls from Ada-1017. Nigel-2654 wrote the preload that slows each sync, and read its code and this description.

Rent-section text checked and edited by Fiona-2757 (GPT-6).

Meg-2646 and others added 2 commits October 16, 2026 16:43
Vector 34c sends 300 coalesced channel messages one at a time and
failed when they took 30 s or more. Each acceptance waits for its
durable commit, a store sync, so the time follows the disk and the
host's load more than the code. In six runs on a Linux host at load
averages of 57 to 78, three of them beside six busy node loops, main's
version took from 10.8 s to 34.1 s and failed once. With each store
sync slowed by 120 ms, it failed both runs on time alone, at 44.2 s and
46.7 s, since 300 syncs at 120 ms take 36 s by themselves.

The bound guarded the cost anima-research#196's review measured, 85 ms a message,
from a journal that synced the store for each of three operations per
message. The test now counts store.sync() calls during the 300 sends
and requires at most 300, so a commit shared between acceptances would
still pass. It turns the framework's periodic store sync off
(syncIntervalMs: 0), which the fixture never starts anyway, so every
sync it counts is an acceptance's. It reports the time beside the
count, and its functional checks are unchanged.

This version passes with each store sync slowed by 120 ms and beside
six busy loops, making 300 store syncs in every run. With each
acceptance committing twice, it fails with 600 store syncs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t holds it

From an internal review of the commit before this one:
- The count had a ceiling and no floor, so a count that stopped seeing
  the commits, or acceptances that stopped committing, read 0 and
  passed. It must now be at least one as well as at most one a message,
  and the ceiling comes from the loop's number of sends.
- The comment credited syncIntervalMs: 0 with making every counted sync
  an acceptance's. start() arms the periodic sync, and this fixture never
  calls it; the option keeps that sync off if one ever does. The comment
  now says so, and that the count takes every store sync during the
  sends.
- The comment said the time is reported, not asserted. Each send still
  has the fixture's reply deadline, its guard against a hang, and the
  comment says that too.

Run with each acceptance's commit removed, the test now fails at the
floor: "300 coalesced messages made no store sync: the acceptances
didn't commit, or the count can't see their commits". With each
acceptance committing twice, it fails at the ceiling with 600 store
syncs, and as it is it passes with 300.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru nissa-seru added the no-changelog Exempts this PR from the changelog check (internal-only change) label Oct 17, 2026
@greptile-apps

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Low impact] This test-only change appears safe to merge.

Summary

Vector 34c replaces its 30-second limit with a count of store.sync() calls during 300 serial messages.

  • Vector 34c counts store syncs instead of timing the run.

Reviews (1) · Last reviewed commit: "test(mcpl): vector 34c's count gets a fl..." · Reviewed by Greptile

@nissa-seru
nissa-seru merged commit 0756302 into anima-research:main Oct 18, 2026
8 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main now carries anima-research#427 and anima-research#431. One conflict, in test/mcpl-coalescing.test.ts's
fixture import: anima-research#427 imports REPLY_DEADLINE_MS and this branch imports
settles(), so the import takes both.

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 18, 2026
…search#429's text

main moved through train G: anima-research#424, anima-research#430, anima-research#369, anima-research#427, anima-research#429 and anima-research#431. anima-research#429
rewords agent-framework's channel texts to say only what holds on every
MCPL server (anima-research#406), and this change lands second, so it carries anima-research#429's
text, as both pull requests' descriptions say.

Ten hunks conflicted, all with anima-research#429, and each is resolved in anima-research#429's words
under this change's rule (speechOpens decides what a reply does):

- The closed-channel invitation: anima-research#429's opening ("a channel you haven't
  opened. Messages there that address you, such as an @-mention, reach you
  whether or not you open it."), its option 2 ("Open the channel") and its
  `#` handling (hashLabel). Option 1 keeps this change's two cases. Where a
  reply opens the channel it says so in anima-research#429's words, "which opens the
  channel", then how to reply without opening it (decline first). Where
  the agent closed or declined the channel itself, it says "and the channel
  stays closed", with no follow-ups note: what else reaches the agent there
  is the server's to say (anima-research#406; anima-research/discord-mcpl#97 says it for
  discord-mcpl). The no-target hint keeps this change's send-tool advice,
  in the same words.
- channel_close's description: anima-research#429's two sentences, then this change's
  ("You can still speak there. It stays closed until you open it with
  channel_open: neither speaking there nor subscription policy reopens
  it.").
- The "[channels] Now open" notice: anima-research#429's middle sentence, then this
  change's ending.
- Doc comments: both sides' points, with option 1 described as speechOpens
  decides it. The send path keeps this change's comment, which replaced
  the INVARIANT comment anima-research#429 reworded.
- trunk-channel-routing.test.ts: both sides' tests. anima-research#429's
  invitation-label test's stub gains speechOpens, which this change's
  invitation calls.

Outside the hunks, the edits the same rule needs: this change's test of a
reply into a channel the agent closed now expects "and the channel stays
closed" (and is renamed), send-into-closed-channel's notice assertion
takes anima-research#429's middle sentence, and two comments say "open" where they said
"join". Train G's other merges touched framework.ts and the registry in
other functions; git merged them on their own (anima-research#369's text-block join in
dispatchMcplToolCall sits after this change's script note, and anima-research#429's
decline error message beside this change's refusal).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-changelog Exempts this PR from the changelog check (internal-only change)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant