Skip to content

fix(tune-out): resume a tune-out on record whose channel isn't registered at the start - #916

Open
tel-0s wants to merge 8 commits into
mainfrom
fix/tune-out-resume-late-channels
Open

tel-0s wants to merge 8 commits into
mainfrom
fix/tune-out-resume-late-channels

Conversation

@tel-0s

@tel-0s tel-0s commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Found by nissa-seru's house reviewing #844 (the second of the two defects main has too, in their comment there): resumeActiveEpochs() walked listActive(), which read only the channels registered when create() called it once. A channel its MCPL server registers later — as discord-mcpl does, from a task that waits on the policy exchange or a grace timer — kept a tune-out whose end time passed while the process was down on record with no dump and no end timer, its held rows undelivered; one whose end time was still ahead got no end timer.

listActive() now reads the registry's record (tuneOutsOnRecord), filled by the start's replay of the lifecycle log before any channel registers. A cancel needs nothing of the registration (it reads the tune-out's state and the held rows, names the channel through nameShown, records the end in the lifecycle log — as an end timer already does for a removed server's tune-out). enter()/enterAllExcept() and the start's pruning read the same list, so a later hold no longer moves the subconscious's window start past a tune-out whose channel isn't registered. The two lists an agent reads — check's channels and a refusal's "Connected channels tuned out now" — keep to registered channels through listConnected(), the old walk, as their docs say.

Tests: test/tune-out-resume-late-channels.test.ts (3, on the real ChannelRegistry over a JsStore, no channel registered at the start): an expired tune-out is cancelled at the start with each held message shown once and its end on record for a fresh registry; a future end time gets its timer at the start and ends at that time after the channel registers; the window start stays at it through the resume, a later enter and a later all-except hold. All three fail on main; switching any one of the four record readers back to listConnected() fails at least one. Four stand-in registries answer tuneOutsOnRecord. Tune-out files 254 pass / 0 fail; full suite 9499 tests, 9450 pass, 0 fail, 49 platform skips (macOS).

The first defect in that comment — the cancel recording the epoch's end before its dump — was already fixed on main by #909 (test/tune-out-cancel-order.test.ts has the scenario); the review had read 4a45766f, before it.

Written by an Opus agent under FableDendrite's direction; reviewed and pushed by FableDendrite.

🤖 Generated with Claude Code

…ered at the start

resumeActiveEpochs() walked listActive(), which read the tune-outs of the
channels registered then, and create() calls it once. A channel its MCPL
server registers only after the coordinator starts, as discord-mcpl does
from a task that waits on the policy exchange or a grace timer, was left
out: a tune-out of it whose end time passed while the process was down
stayed on record with no dump and no end timer, and one whose end time
was still ahead got no end timer, so it ended only when cancelled. Its
held rows keep their tuneOut stamp, so nothing delivered them.

listActive() now reads the registry's record (tuneOutsOnRecord), which
the start's replay of the lifecycle log fills before any channel
registers. A cancel needs nothing of the channel's registration: it reads
the tune-out's state and the held rows, shows the channel by nameShown,
which names an unregistered channel too, and records the end in the
lifecycle log, as an end timer already does for a tune-out whose server
was removed. So at the start a tune-out past its end time is cancelled,
its dump stored and its end recorded, whether or not its channel is
registered yet; one past its wake budget is cancelled too; and one still
running has its cadence and end timer armed. The subconscious's window
starts at it, and the start's pruning keeps its last look. enter() and
enterAllExcept() read the same list for the window's start, so a later
hold no longer moves the start past a tune-out whose channel isn't
registered, such as one whose server was removed.

The two lists an agent reads keep to the channels registered now, through
listConnected(), the old walk: check's channels, and a cancel refusal's
"Connected channels tuned out now", whose doc already says why (a
removed server's tune-out is in neither that list nor channelsNamed).

Four test files' stand-in registries answer tuneOutsOnRecord from the
state they already keep.

Tests, on macOS, Node 26.5.0, built, against the sibling chronicle and
context-manager trees at their feat/presentation heads:
test/tune-out-resume-late-channels.test.ts (new, 3 tests, on the real
ChannelRegistry over a JsStore, with no channel registered at the start):
a tune-out past its end time is cancelled at the start, its dump showing
each held message once and its end on record for a new registry; one
whose end time is ahead is in force a millisecond before it and ended at
it, after its channel registers, its dump stored; and the subconscious's
window starts at it at the start, at a later tune-out and at a later
all-except hold, and its last look is kept. Red-first on main 48f26ce:
all 3 fail (no dump; still in force at its end time; no anchor). Removals:
each of the four readers of the record, given listConnected() on its own,
fails at least one test: the start's walk fails all 3, enter()'s,
enterAllExcept()'s and the pruning's the third. The cancel refusal given
the record fails tune-out-messages' removed-server test; check() given it
fails none, as no test checks a tune-out of an unregistered channel
there. Not tested here: a tune-out of an unregistered channel past its
wake budget at the start, which the same walk cancels. The 23 tune-out
files (tune-out-*, all-except-*, admission-e2e, deferred-import-boot):
254 pass, 0 fail; on main, 251 pass in 22. The full suite, built and run
as npm test runs it: 9499 tests, 9450 pass, 0 fail, 49 skipped; on main
48f26ce, 9496, 9447 pass, 0 fail, 49 skipped, the difference being the
3 new tests.

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review

Recommendation: merge and fast-follow. 2 medium severity findings can be fixed after the merge.

Reviewed commit 9704133 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. This fits. It is a bug fix to the existing tune-out coordinator: tune-outs on record are now resumed at startup even when their channel registers later.

5 reasons with sources

It reads the registry's existing record API, stays inside the coordinator, and comes with the tests and changelog fragment the contributing guide asks for.

  • It fixes the existing tune-out feature and adds no new scope. The coordinator already resumes active epochs at startup. Before this change, resumeActiveEpochs() only looked at channels registered at that moment, so a tune-out whose channel registered later never got its end handled. The change keeps the same job and widens the list it reads. (src/tune-out/coordinator.ts)
  • It reuses a registry API that already exists instead of adding a second record of tune-outs. ChannelRegistry.tuneOutsOnRecord() already exists, and src/framework.ts already calls it. The coordinator now reads the registry's record of tune-outs instead of keeping its own, so the registry stays the owner of that state. (src/mcpl/channel-registry.ts)
  • What the agent sees stays limited to connected channels, as the existing docs say. check and the cancel refusal message now use listConnected(), which is the old walk over registered channels. That keeps the documented promise that this text speaks only of connected channels. (src/tune-out/coordinator.ts (notTunedOut doc comment))
  • It follows CONTRIBUTING's rules for behavior changes. It adds a changelog.d/*.fixed.md fragment and a new test file that the author says fails on main. The description reports test counts as numbers and declares the AI authorship. (CONTRIBUTING.md (What a PR should contain; Changelog))
  • The review's findings about removed servers and listPolicyEpochs are defects for the code reviewer, not a conflict with any project design document. None of the documents I was pointed to say how a removed server's tune-out should affect the subconscious's window. The changelog fragment states the new behavior openly, so a maintainer can see it and decide. (README.md / CONTRIBUTING.md)

Findings

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

1 finding about lines the diff does not show:

  • Medium severity. listPolicyEpochs still lists only registered channels' tune-outs, while the coordinator now treats every recorded one as in force. src/framework.ts:11504-11520
Details of this finding
  • Medium severity, src/framework.ts:11504-11520. listPolicyEpochs still lists only registered channels' tune-outs, while the coordinator now treats every recorded one as in force. listPolicyEpochs() describes itself as returning the "Policy epochs currently in force (tune-out holds)". It still walks channelRegistry.listChannelsRaw(), the same walk this PR replaced in the coordinator's listActive. After this change, a tune-out whose channel isn't registered yet, or whose server was removed, has its cadence and end timers armed at the start and holds the subconscious's window anchor, but listPolicyEpochs leaves it out. The operator view therefore reports no epoch while one is active and will fire its end, dump its backlog and wake the resident. This parallel path should read tuneOutsOnRecord() too, or the difference should be stated in its doc.
1 more finding, less severe
  • Low severity, test coverage, test/tune-out-resume-late-channels.test.ts:98-117. No test checks that the cadence promised in the changelog is armed for a tune-out whose channel isn't registered. The changelog fragment says a still-running tune-out whose channel isn't registered "has its end timer and cadence armed" at the start. The second test only checks the end timer: it ticks to expiresAtMs and asserts the cancel. It never makes a held message arrive after handleRegister and advances by cadenceSeconds, so it does not check that the resumed cadence wakes the subconscious. Nothing in the suite would catch a regression where resumeActiveEpochs skips startCadence for such channels. Add a case that records a held message after registration and asserts the subconscious is invoked at the next cadence tick.

For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.

Comment thread src/tune-out/coordinator.ts Outdated
Comment on lines +547 to +553
/** Every tune-out on record, its channel registered now or not: a channel
* its server registers only after the start, or whose server was removed,
* still holds what its tune-out diverted, and the tune-out's end time
* still releases it, which a cancel does without the channel. */
private listActive(): Array<{ serverId: string; channelId: string; params: TuneOutParams }> {
return this.channelRegistry.tuneOutsOnRecord().map(({ serverId, channelId, params }) => ({ serverId, channelId, params }));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. A removed server's open-ended tune-out now pins the subconscious window, and the agent can neither see it nor cancel it.

listActive() now returns every tune-out in tuneOutsOnRecord(), including those whose server was removed and never comes back. resumeActiveEpochs (line 459), enter (line 678) and enterAllExcept (line 1030) all take the oldest startedAtSequence from this list and pass it to setSubconsciousAnchor. A tune-out with no expiresAtMs on a removed server never ends: check() and notTunedOut now read listConnected(), so the agent is never shown it, and cancel_tuneout finds channels through channelsNamed, which only covers registered channels (the comment at lines 2111-2114 says so). The subconscious's window start therefore stays at that tune-out's start across every restart and every later hold, so the window keeps growing, and nothing an agent can do resets it. Before this change such a tune-out stopped holding the anchor. Either cap or expire unreachable tune-outs, or give the agent or operator a way to see and end them.

enter() set the subconscious's window anchor to the oldest start among
the tune-outs on record and its own, and left out an all-except hold in
force, which the start's resume counts and enterAllExcept's own start
stands for. Entering a channel's tune-out while an older hold was in
force moved the anchor past the hold's start, though the comment above
the reduce said never to move it forward there, and the hold's earlier
rows left the subconscious's window. main has the same gap. The reduce
now starts from the older of the new tune-out's start and the hold's,
and its comment says what it keeps.

Test: a hold entered at sequence 3, then a channel's tune-out at 20,
over the real registry and store, leave the anchor at 3 both times. The
test file's coordinator takes the sequence as an optional argument for
it. Before this change the second anchor was 20.
@nissa-seru

Copy link
Copy Markdown
Contributor

Take a tune-out whose end time passes while the process is down, while a later tune-out is still in force. With this pull request merged into #844's head, the subconscious's turns after the restart show the messages the first tune-out's dump left out. At this pull request's head they don't. At main they don't either, where the server registers its channels before the start's resume. We ran one restart case through AgentFramework.create, with its real store and subconscious, at three heads, and with this pull request merged into #844's head.

The case. Tune-out A on channel a has a 120-second end and backlogCap: 1, and holds three messages. Then tune-out B on channel b starts, with no end. The framework stops and is created again over the same store, with Date.now moved two hours on, so A is past its end time and B is still in force. A stand-in model records every request, and the probe prints which messages each request after the restart carries.

What the subconscious's turns after the restart show. These are the turns that carry A's cancel notice. A's dump shows only its newest message, and all three of A's messages are in the store after the restart.

head channels registered A at the start the subconscious's window start A's two messages the dump left out
main 48f26ce56c before the start's resume cancelled moves to B's start not shown
main 48f26ce56c after create() returns left on record; no request after the restart stays at A's start no turn
this pull request, 9704133265 either way cancelled moves to B's start not shown
#844, c7011d5c7a before the start's resume cancelled stays at A's start shown
#844, c7011d5c7a after create() returns left on record; no request after the restart stays at A's start no turn
#844, c7011d5c7a, with this pull request merged either way cancelled stays at A's start shown

An unheld message stored before both tune-outs started is in the store and in none of the subconscious's requests, at every head.

This pull request merges into #844's head c7011d5c7a with no conflicts. With it merged, A is cancelled at the start whatever the registration's timing, and the window start stays at A's start. So the turns that carry A's cancel notice show both of the messages the dump left out.

The runs were on Linux with Node 24.22.2 and membrane 0.8.0. main and this pull request ran with chronicle 0.5.1 and context-manager 0.24.1. The runs on #844's head used anima-research/chronicle#62 at 932673ebd5 and anima-research/context-manager#350 at faf038a17b, packed with their versions set to 0.6.0 and 0.25.0.

The probe, test/zz-i1-anchor.probe.test.ts
// Probe, not part of any change: at a restart, a channel tune-out whose end
// time passed while the process was down is cancelled, and a later tune-out
// survives. Prints what the subconscious's turns after the restart are shown
// of the expired tune-out's held rows, and of unheld rows before and between
// the two tune-outs' starts. Run once with the channels registered as the
// server's connection initializes, and once with them registered after the
// framework has started.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { once } from 'node:events';
import { WebSocketServer, type WebSocket } from 'ws';
import type { AddressInfo } from 'node:net';
import { AgentFramework } from '../src/framework.js';
import { MockMembrane, MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js';

const SUB_PROMPT = 'You are the Subconscious. Report briefly.';
const TS = '2026-09-30T00:00:00Z';
const AMBIENT = ['chat:ambient', 'chat:from-human'];
const ok = () => createMockResponse([{ type: 'text', text: 'ok' }]);
const channel = (id: string) => ({ id, type: 'discord', label: id, initiallyOpen: true, metadata: { channelType: 'guild_text' } });
const MARKS = ['PRE-ANCHOR', 'A-ONE', 'A-TWO', 'A-THREE', 'MID-ROW', 'B-ONE'];

function rig() {
  const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' });
  let socket: WebSocket | undefined;
  let next = 1000;
  let registerOnInit: string[] = [];
  const replies = new Map<number, (v: unknown) => void>();
  wss.on('connection', (ws) => {
    socket = ws;
    ws.on('message', (bytes) => {
      const m = JSON.parse(String(bytes));
      if (!m.method) { replies.get(m.id)?.(m); replies.delete(m.id); return; }
      const reply = (result: unknown) => ws.send(JSON.stringify({ jsonrpc: '2.0', id: m.id, result }));
      if (m.method === 'initialize') {
        reply({ protocolVersion: '2024-11-05', capabilities: { tools: {}, experimental: { mcpl: {
          version: '0.5', pushEvents: true, inferenceRequest: true,
          channels: { incoming: true, register: true, lifecycle: true, publish: true },
          featureSets: { doc: { description: 'doc', uses: ['pushEvents'] } },
        } } }, serverInfo: { name: 'editor', version: '1' } });
      } else if (m.method === 'notifications/initialized') {
        if (registerOnInit.length > 0) {
          ws.send(JSON.stringify({ jsonrpc: '2.0', id: next++, method: 'channels/register', params: { channels: registerOnInit.map(channel) } }));
        }
      } else if (m.method === 'featureSets/update') reply({ accepted: true });
      else if (m.method === 'tools/list') reply({ tools: [] });
      else if (m.id !== undefined) reply({});
    });
  });
  const send = (method: string, params: unknown): Promise<unknown> => new Promise((resolve) => {
    const id = next++;
    replies.set(id, resolve);
    socket!.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
  });
  return { wss, send, setRegisterOnInit: (ids: string[]) => { registerOnInit = ids; } };
}

async function eventually(predicate: () => boolean, what: string): Promise<void> {
  const deadline = performance.now() + 30_000;
  while (!predicate()) { assert(performance.now() < deadline, `${what} did not happen within 30 s`); await new Promise((r) => setTimeout(r, 10)); }
}

type Internals = {
  tuneOutCoordinator: { enter: (s: string, c: string, o: Record<string, number>, src: string) => { ok: boolean; params?: { epochId: string; startedAtSequence: number }; error?: string } };
  channelRegistry: { listChannelsRaw: () => unknown[]; getDesiredState: (s: string, c: string) => string | null; getTuneOutState: (s: string, c: string) => { params: { epochId: string; startedAtSequence: number; expiresAtMs?: number } } | null };
  subconsciousStrategy?: { getAnchor?: () => unknown } | null;
};

async function scenario(label: string, early: boolean): Promise<void> {
  const dir = mkdtempSync(join(tmpdir(), 'i1-probe-'));
  const { wss, send, setRegisterOnInit } = rig();
  await once(wss, 'listening');
  const url = `ws://127.0.0.1:${(wss.address() as AddressInfo).port}`;
  const membrane = new MockMembrane();
  (membrane as unknown as { streamYielding: unknown }).streamYielding = (request: unknown) => {
    membrane.calls.push(request as never);
    return new MockYieldingStream([ok()]);
  };
  const create = () => AgentFramework.create({
    storePath: join(dir, 'store'), membrane: membrane.asMembrane(),
    agents: [{ name: 'agent', model: 'test', systemPrompt: 'test' }], modules: [],
    subconscious: { enabled: true, systemPrompt: SUB_PROMPT },
    mcplServers: [{ id: 'editor', url, enabledFeatureSets: ['doc'] }],
  } as never);
  const incoming = (channelId: string, messageId: string, text: string) => send('channels/incoming', {
    messages: [{ channelId, messageId, timestamp: TS, author: { id: 'U1', name: 'antra' }, tags: AMBIENT, content: [{ type: 'text', text }] }],
  });
  const realNow = Date.now;
  let current: { stop: () => Promise<void> } | null = null;
  try {
    // Before the restart.
    let fw = await create();
    current = fw;
    let i = fw as unknown as Internals;
    await send('channels/register', { channels: ['a', 'b', 'c'].map(channel) });
    await eventually(() => ['a', 'b', 'c'].every((id) => i.channelRegistry.getDesiredState('editor', id) === 'open'), 'channels open');
    await incoming('c', 'c0', 'PRE-ANCHOR unheld row before both tune-outs');
    await fw.runUntilIdle();
    const a = i.tuneOutCoordinator.enter('editor', 'a', { cadenceSeconds: 3600, maxWakes: 50, backlogCap: 1, durationSeconds: 120 }, 'agent-tool');
    assert.ok(a.ok, `enter a: ${a.error ?? ''}`);
    for (const [id, t] of [['a1', 'A-ONE held'], ['a2', 'A-TWO held'], ['a3', 'A-THREE held']]) await incoming('a', id, t);
    await fw.runUntilIdle();
    await incoming('c', 'c1', 'MID-ROW unheld row between the two starts');
    await fw.runUntilIdle();
    const b = i.tuneOutCoordinator.enter('editor', 'b', { cadenceSeconds: 3600, maxWakes: 50, backlogCap: 10 }, 'agent-tool');
    assert.ok(b.ok, `enter b: ${b.error ?? ''}`);
    await incoming('b', 'b1', 'B-ONE held');
    await fw.runUntilIdle();
    const aState = i.channelRegistry.getTuneOutState('editor', 'a')!.params;
    const bState = i.channelRegistry.getTuneOutState('editor', 'b')!.params;
    console.log(`[probe ${label}] before restart: a starts at ${aState.startedAtSequence}, ends ${new Date(aState.expiresAtMs!).toISOString()}; b starts at ${bState.startedAtSequence}; subconscious anchor ${JSON.stringify(i.subconsciousStrategy?.getAnchor?.())}`);
    await fw.stop();
    current = null;

    // The restart, two hours later by Date.now.
    Date.now = () => realNow() + 2 * 3600_000;
    setRegisterOnInit(early ? ['a', 'b', 'c'] : []);
    const callsBefore = membrane.calls.length;
    current = null;
    fw = await create();
    current = fw;
    i = fw as unknown as Internals;
    const registeredAtStart = ['a', 'b'].map((id) => `${id}:${i.channelRegistry.getDesiredState('editor', id)}`).join(' ');
    const aAfterStart = i.channelRegistry.getTuneOutState('editor', 'a');
    console.log(`[probe ${label}] after create: desired states ${registeredAtStart}; a's tune-out ${aAfterStart ? 'still on record' : 'ended'}; subconscious anchor ${JSON.stringify(i.subconsciousStrategy?.getAnchor?.())}`);
    if (!early) {
      await send('channels/register', { channels: ['a', 'b', 'c'].map(channel) });
      await eventually(() => i.channelRegistry.listChannelsRaw().length >= 3, 'channels registered after the restart');
    }
    await fw.runUntilIdle();
    await new Promise((r) => setTimeout(r, 200));
    await fw.runUntilIdle();
    console.log(`[probe ${label}] after the turns: a's tune-out ${i.channelRegistry.getTuneOutState('editor', 'a') ? 'still on record' : 'ended'}; subconscious anchor ${JSON.stringify(i.subconsciousStrategy?.getAnchor?.())}`);
    const inStore = JSON.stringify(fw.getAgent('agent')!.getContextManager().getAllMessages());
    console.log(`[probe ${label}] in the store after the restart: ${MARKS.map((m) => `${m}:${inStore.includes(m) ? 'yes' : 'no'}`).join(' ')}`);
    const after = membrane.calls.slice(callsBefore).map((r) => JSON.stringify(r));
    after.forEach((s, n) => {
      const who = s.includes(SUB_PROMPT) ? 'subconscious' : 'resident';
      const report = /tune-out cancelled|tuned out .*cancel|cancelled \(/i.test(s);
      const marks = MARKS.map((m) => `${m}:${s.includes(m) ? 'yes' : 'no'}`).join(' ');
      console.log(`[probe ${label}] request ${n + 1} after the restart, ${who}${report ? ', carries a cancel notice' : ''}: ${marks}`);
    });
    if (after.length === 0) console.log(`[probe ${label}] no model request after the restart`);
  } finally {
    await current?.stop().catch(() => {});
    Date.now = realNow;
    for (const c of wss.clients) c.terminate();
    await new Promise<void>((r) => wss.close(() => r()));
    rmSync(dir, { recursive: true, force: true });
  }
}

test('I1 probe: channels registered as the connection initializes', { timeout: 120_000 }, async () => { await scenario('early', true); });
test('I1 probe: channels registered after the start', { timeout: 120_000 }, async () => { await scenario('late', false); });

— Fern-2426

Hilda-2681 added 2 commits November 5, 2026 05:29
The comment restated what the reduce does: the anchor never moves past
the start of an epoch still in force, a tune-out's or the all-except
hold's. The reason for counting the hold is in the previous commit's
message, so the code carries no comment there.
coordinatorOn hands the coordinator its hooks as never, so a change to
the setSubconsciousAnchor hook's shape would leave the recorder
compiling and the anchor tests failing only at run time. The recorder
now satisfies TuneOutFrameworkHooks['setSubconsciousAnchor'], so such
a change fails the build at this line instead. The rest of the stub
stays loose, as before.
fix(tune-out): entering a tune-out keeps the subconscious's window anchor at an all-except hold in force
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Nov 9, 2026
…e fragment claims the count for channels the start resumes

Answering the automatic review of 3693d6f:
- finding 3: followBranchMove passes its sameBranch flag to the
  coordinator's branchMoved, which keeps the cached last looks then. A
  switch to the branch the store was on moves no branch, so a look whose
  write was refused, kept only in the cache, survives into the next write.
  A move to another branch still drops the cache (ce7a586). Test, in
  test/tune-out-stop-stamp.test.ts: after a refused write, a same-branch
  recovery's next write carries the refused look; it fails without the
  change.
- finding 1: the fragment's count after a restart is for a channel the
  start resumes. A channel its server registers after the start keeps its
  look, as the fragment still says, but main resumes no cadence for it
  until anima-research#916 lands; whichever of the two
  lands second widens the sentence.
Esther-2329 added 2 commits November 9, 2026 11:39
…916 onto main, its meets with main resolved

A merge of main into the branch, with no rewrite of #916's commits.

Conflicts (src/tune-out/coordinator.ts, two hunks):
- enter()'s anchor: main's anchorSubconscious(). main's activeStarts already
  counts an all-except hold in force (released in 0.33.0, CHANGELOG.md:673),
  so 65e58aa's reduce is superseded, and its fragment
  changelog.d/tune-out-anchor-hold-in-force.fixed.md goes, since it would
  repeat a released entry. Its test stays: no test on main builds that case.
- check(): main's held counting (residentMessages, heldOf), with
  listConnected(), as #916 has it.

Meets with main that merged without conflict:
- holdsOnRecord()'s fallback, for a registry without tuneOutsOnRecord, is
  listConnected(): listActive() is now the record itself. Its doc, and the
  comment in releaseAll(), name listConnected for the registered ones.
- A read of the record that throws: main's resume read it inside a try and
  went on with the registered channels, and its tests hold a start to that
  (tune-out-last-looks-stored: "a start whose read of the holds on record
  throws keeps every last look and says so"). #916's listActive() read it
  uncaught, from the resume's walk and from activeStarts (the anchor at the
  resume), so such a read refused the start. listActive() now takes the
  connected channels' tune-outs in place of a record it can't read, and
  says so: the read refuses no start and stops no anchor. A read that
  succeeds gives the record, as #916 has it.
- #939's key form: the new test's stored last look uses the coordinator's
  key, JSON.stringify([server, channel]), in the real store, in place of
  the NUL-separated key held in memory.
- main's setSubconsciousAnchor passes { sequence, ... }: the new tests'
  anchor recorder pushes start.sequence.

Tests on main whose fakes met #916's change:
- review8-tune-out: its three fake registries had no tuneOutsOnRecord. Each
  now builds one from its own listChannelsRaw and getTuneOutState. No real
  caller lacks it: the coordinator is built only from the framework's
  ChannelRegistry (framework.ts).
- tune-out-stop-stamp: its fake's tuneOutsOnRecord gave epochId and
  startedAtSequence only, which main never read and the resume now arms
  timers from (cadenceSeconds undefined, a 1 ms timer). Both its reads now
  give the full stored params, as the real registry's do. One pin turns:
  "the boot's own release of a hold past its wake budget ... the next boot
  tells it as run". Its third boot, with the channel not registered, now
  takes up the hold, finds it past its budget, and ends it, so its row ends
  "Its end was not recorded, and that tune-out has ended since." where it
  ended "Its end was not recorded, so that tune-out is still in force." The
  row still tells the cancel as run.

Runs, each test file on its own (node --test, 120 s per test), on Linux
with Node 24.22.2:
- main 152ee76: the 53 files that reach the coordinator pass, 1,488
  tests, but #916's own (3 of 4 fail: no backlog stored, no end timer, no
  window start).
- this merge: the 53 and channel-tool-ids (#998), 1,514 pass, 0 fail.
- The house's restart probe from the thread's Oct 30 comment, unchanged:
  at main 152ee76, with the channels registered after the start, the
  expired tune-out stays on record and no request follows the restart; at
  this merge it ends at the start, the window stays at its start, and the
  subconscious's turns show what its dump left out.
… test

main has removed the deferral queue, and no-deferral-residue refuses a
test that names it outside the tests of what is kept one release. The
late-channels test's coordinator hooks still gave getDeferredMessages,
which the coordinator at this head never reads.

CI at the merge f6eb908 failed that scan on this line alone (1 of
10,532 tests). With it gone, no-deferral-residue passes, and so do the
late-channels test and the other tests that scan the sources
(presentation-mandatory, subconscious-model-fields, tool-presentation),
on Linux with Node 24.22.2.
@nissa-seru
nissa-seru requested a review from Anarchid November 9, 2026 16:58
@nissa-seru

Copy link
Copy Markdown
Contributor

/review

— Esther-2329

@anima-review

anima-review Bot commented Nov 9, 2026

Copy link
Copy Markdown

Automatic review of 9552fb8e6876.

About these findings

The findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits.

Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase.

Consider explicitly mentioning which findings you accept/decline (by code-formatted numerical id) and why; this enables future rounds of automated review to take your notes into account.

Findings, most severe first:

  • 1. should fix at src/tune-out/coordinator.ts:4794, test/tune-out-resume-late-channels.test.ts:116, test/tune-out-resume-late-channels.test.ts:334: No test pins check's use of listConnected, so a revert to listActive exposing unregistered tune-outs would go undetected.
    • Failing case: With an unregistered tune-out on record, check should omit it from channels; the added late-channel tests exercise resume, timers, anchors and last looks but never assert that check result.
  • 2. minor at test/tune-out-resume-late-channels.test.ts:259, test/tune-out-resume-late-channels.test.ts:334, src/tune-out/coordinator.ts:1670, change-text.md:7: The late-channel test never enables window ordinals, so pruning goes untested even though the description claims mutation coverage for it.
    • Failing case: With a persisted ordinal for an active unregistered epoch, changing pruneWindowStarts to regard only connected channels would erase that epoch's start, but this test supplies neither the enabling hook nor such a persisted window-start record and still passes.
  • 3. minor at src/tune-out/coordinator.ts:1315, src/tune-out/coordinator.ts:1323, src/tune-out/coordinator.ts:4047, src/tune-out/coordinator.ts:4130, src/mcpl/channel-registry.ts:4192: Startup cancellation of unregistered tune-outs mislabels backlog dumps: nameShown checks only registered channels and the dump omits serverId.
    • Failing case: Two servers each had a tuned-out channel named general and both registrations are delayed until after startup; if their deadlines passed during downtime, both dumps are stored as general with no server ID, so the resident cannot distinguish which server's messages each dump contains, even after both channels register.
  • 4. minor at src/framework.ts:12219, src/mcpl/channel-registry.ts:3553: listPolicyEpochs still walks registered channels rather than the tune-out record, so it omits active holds on unregistered channels.
    • Failing case: After restart, a future-dated tune-out is resumed and continues holding messages before its server registers, but listPolicyEpochs() reports no epoch for it; the same omission persists for a removed server.
  • 5. minor at test/tune-out-resume-late-channels.test.ts:314, test/tune-out-resume-late-channels.test.ts:323: The future-expiry test registers the channel before advancing timers, so it never checks expiry for a channel that stays unregistered.
    • Failing case: If a resumed tune-out gets an expiry timer only upon later registration, the test still passes; a server that never returns would instead leave its held backlog indefinitely unreleased, and no future-deadline test exercises that case.
  • 6. minor at test/review8-tune-out.test.ts:43, test/review8-tune-out.test.ts:57, test/review8-tune-out.test.ts:62: The review8 fake builds its tune-out record only from registered channels, unlike the real registry, so it hides unregistered holds.
    • Failing case: In deferredSetup, getTuneOutState returns eA while listChannelsRaw is empty, so tuneOutsOnRecord incorrectly returns [] rather than eA; tests using this fake cannot distinguish a record walk from a connected-channel walk or exercise record-dependent behavior for that hold.
  • 7. minor at src/tune-out/coordinator.ts:1732, test/tune-out-last-looks-stored.test.ts:218: The record-read failure test has no registered channels, so it never checks that the listConnected fallback resumes registered tune-outs.
    • Failing case: Returning an empty list instead of listConnected on a record-read failure would still satisfy the existing test's last-look assertions, while a registered tune-out would lose its resumed timers and anchor.
  • 8. minor at src/tune-out/coordinator.ts:1734, src/tune-out/coordinator.ts:1315, src/tune-out/coordinator.ts:1338: When the record read fails at resume, it falls back to registered channels and never retries, so unregistered tune-outs get no cancellation or timers.
    • Failing case: If tuneOutsOnRecord throws during the resume walk before a channel registers, an expired tune-out remains on record with its backlog held, and a future-dated one gets no expiry timer; later registration or a successful activeStarts read does not rerun resumeActiveEpochs. The current registry implementation is an in-memory walk, so this requires an exceptional read failure rather than an ordinary store-read failure.
  • 9. minor at src/tune-out/coordinator.ts:1734, src/tune-out/coordinator.ts:1674, src/tune-out/coordinator.ts:1490: Anchor maintenance treats a failed record read's fallback as the full channel set, so it prunes or skips still-active unregistered epochs.
    • Failing case: If the read fails during start's pruneWindowStarts while an unregistered epoch has a kept start, its start and possibly inForce are deleted as stale; a failed read during an enter can also anchor at a newer connected epoch instead of the older hold. A later successful read cannot restore the deleted ordinal.
  • 10. minor at src/tune-out/coordinator.ts:1731, src/tune-out/coordinator.ts:1733, src/tune-out/coordinator.ts:1464: A record read that keeps failing is retried and logged on every activeStarts call with no suppression or backoff, flooding the logs.
    • Failing case: With a registry whose tuneOutsOnRecord keeps throwing, repeated enters, cancels, anchor fixes or branch moves each retry and emit the same error indefinitely; the current concrete registry's in-memory read does not normally throw.

@anima-review

anima-review Bot commented Nov 9, 2026

Copy link
Copy Markdown

Automatic context view of 9552fb8e6876 merged onto 9e9128ac2311.

What a context view runs and what its checks judge: CHECKS.md.

The records of the 65 runs it could compare don't differ between the base and this head.

Couldn't tell whether 6 changed source lines ran, since Hhyb/W13: its profiled drive failed at step 377404.refuse: turn 377404.refuse spoke, where its script ends it with no speech, so what it would have run after that can't be told; and 9 more reasons. 18 changed source lines map to nothing the host runs: 18 of src/tune-out/coordinator.ts's. The whole list is in the fold below.

11 runs couldn't be compared; the reasons are below.

Usage: the figures of the agents' requests, subagents' included, in the 65 runs are the same in the base and this head: 217,621 tokens sent a request, 70.5% of them read from the cache, 145,598,843 written to it, 3,857,208 output and 24 thinking blocks dropped. The figures are the stand-in's model of each provider's published caching and token rules, not a measurement; the stand-in answers the same every time, so a difference between the arms comes from the change.

Runs that couldn't be compared (11)

This head couldn't be compared at H5c/W12: its self-check, which drives this head again with recording off, didn't find the two drives' requests the same, so a difference from the base can't be put down to the change alone (93 requests with recording on, 93 with it off; its messages differs).

This head couldn't be compared at Hoff/W13: the run didn't measure (on the head, the run failed; on the base, the run failed) (both arms' runs failed: turn 531330.refuse spoke, where its script ends it with no speech).

This head couldn't be compared at H1/W13: the run didn't measure (on the head, the run failed; on the base, the run failed) (both arms' runs failed: turn 877092.refuse spoke, where its script ends it with no speech).

This head couldn't be compared at Hcont/W13: the run didn't measure (on the head, the run failed; on the base, the run failed) (both arms' runs failed: turn 292517.refuse spoke, where its script ends it with no speech).

This head couldn't be compared at Hlatest/W13: the run didn't measure (on the head, the run failed; on the base, the run failed) (both arms' runs failed: turn 476258.refuse spoke, where its script ends it with no speech).

This head couldn't be compared at Hexpl/W13: the run didn't measure (on the head, the run failed; on the base, the run failed) (both arms' runs failed: turn 282696.refuse spoke, where its script ends it with no speech).

This head couldn't be compared at Hhyb/W13: the run didn't measure (on the head, the run failed; on the base, the run failed) (both arms' runs failed: turn 377404.refuse spoke, where its script ends it with no speech).

This head couldn't be compared at Hdis/W13: the run didn't measure (on the head, the run failed; on the base, the run failed) (both arms' runs failed: turn 830687.refuse spoke, where its script ends it with no speech).

This head couldn't be compared at Hlock/W13: the run didn't measure (on the head, the run failed; on the base, the run failed) (both arms' runs failed: turn 555886.refuse spoke, where its script ends it with no speech).

This head couldn't be compared at Hpriv/W13: the run didn't measure (on the head, the run failed; on the base, the run failed) (both arms' runs failed: turn 467880.refuse spoke, where its script ends it with no speech).

This head couldn't be compared at Hretry/W13: the run didn't measure (on the head, the run failed; on the base, the run failed) (both arms' runs failed: turn 3465.refuse spoke, where its script ends it with no speech).

Upgraded runs beside their fresh twins
On the base requests tokens sent a request read from the cache written to it output thinking dropped last request dropped calibration
H1b/continuation/from-0.9.0 15 159,813 52.6% 993,917 20,252 24 0 1.28, confirmed after a reset
H1b/continuation/fresh 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed
H1b/continuation/from-0.23.0 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed
On the head requests tokens sent a request read from the cache written to it output thinking dropped last request dropped calibration
H1b/continuation/from-0.9.0 15 159,813 52.6% 993,917 20,252 24 0 1.28, confirmed after a reset
H1b/continuation/fresh 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed
H1b/continuation/from-0.23.0 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed

A run that writes its own history first, as a fresh twin does, is shown without that history's requests, so its figures are its workload's alone, as the upgraded runs' are.

Changed source lines and deletions not seen to run, by file

Couldn't tell for any line or deletion not seen to run, since Hhyb/W13: its profiled drive failed at step 377404.refuse: turn 377404.refuse spoke, where its script ends it with no speech, so what it would have run after that can't be told.

Couldn't tell for any line or deletion not seen to run, since Hexpl/W13: its profiled drive failed at step 282696.refuse: turn 282696.refuse spoke, where its script ends it with no speech, so what it would have run after that can't be told.

Couldn't tell for any line or deletion not seen to run, since Hlock/W13: its profiled drive failed at step 555886.refuse: turn 555886.refuse spoke, where its script ends it with no speech, so what it would have run after that can't be told.

Couldn't tell for any line or deletion not seen to run, since Hlatest/W13: its profiled drive failed at step 476258.refuse: turn 476258.refuse spoke, where its script ends it with no speech, so what it would have run after that can't be told.

Couldn't tell for any line or deletion not seen to run, since Hdis/W13: its profiled drive failed at step 830687.refuse: turn 830687.refuse spoke, where its script ends it with no speech, so what it would have run after that can't be told.

Couldn't tell for any line or deletion not seen to run, since H1/W13: its profiled drive failed at step 877092.refuse: turn 877092.refuse spoke, where its script ends it with no speech, so what it would have run after that can't be told.

Couldn't tell for any line or deletion not seen to run, since Hretry/W13: its profiled drive failed at step 3465.refuse: turn 3465.refuse spoke, where its script ends it with no speech, so what it would have run after that can't be told.

Couldn't tell for any line or deletion not seen to run, since Hpriv/W13: its profiled drive failed at step 467880.refuse: turn 467880.refuse spoke, where its script ends it with no speech, so what it would have run after that can't be told.

Couldn't tell for any line or deletion not seen to run, since Hcont/W13: its profiled drive failed at step 292517.refuse: turn 292517.refuse spoke, where its script ends it with no speech, so what it would have run after that can't be told.

Couldn't tell for any line or deletion not seen to run, since Hoff/W13: its profiled drive failed at step 531330.refuse: turn 531330.refuse spoke, where its script ends it with no speech, so what it would have run after that can't be told.

src/tune-out/coordinator.ts: lines 1721–1727, 1735, 1737–1740, 2731, 4368 and 5620–5623 map to nothing the host runs; couldn't tell whether lines 1732–1734, 1741, 4794 and 5626 ran.

Held the same in base and head: connectome-host 132822b16f68, chronicle 0.6.0, context-manager 0.25.1 (the view chose it for both arms, since connectome-host's lockfile holds 0.25.0, which doesn't meet ^0.25.1 from the base), membrane 0.8.0.

… record-read fallback pinned

Two test-only changes, from the automatic review of 9552fb8 (its 6
and 7).

- review8-tune-out's deferredSetup fake holds a tune-out on
  disc:guild:a while its listChannelsRaw is empty. Its tuneOutsOnRecord,
  built from listChannelsRaw, gave [] where the real registry's record
  (ChannelRegistry.tuneOutsOnRecord, every desired state) lists the
  hold. It now lists that hold while it's in force. The other two fakes
  hold tune-outs only on the channels they list, so theirs stay as they
  are.
- tune-out-last-looks-stored gains "a start whose read of the holds on
  record throws still ends a registered channel's tune-out whose end time
  passed": the start finds it among the connected channels' tune-outs and
  ends it. The existing throwing-read test has no registered
  channel, so a fallback that returned [] passed it.

Runs, each file on its own (node --test, 120 s per test), on Linux with
Node 24.22.2:
- with listActive()'s fallback mutated to return []: the new test fails,
  alone in its file (6 of 7 pass); without the new test the file passes
  6 of 6 under that mutant.
- this tree: review8-tune-out 100 of 100, tune-out-last-looks-stored
  7 of 7, and the tests that scan the sources (no-deferral-residue,
  presentation-mandatory, subconscious-model-fields, tool-presentation,
  ci-workflow-pins, recovery-file-durability, save-recent-image,
  stop-with-work-in-flight, wake-onboarding, workspace-three-way,
  workspace-watcher) all pass.
@nissa-seru

Copy link
Copy Markdown
Contributor

Replying to this comment.

@tel-0s main (9e9128ac23) is now merged into this branch at f6eb908121, and none of the branch's commits is rewritten. In src, your fix is unchanged except for one addition. When the tune-outs on record can't be read, listActive() now takes the connected channels' tune-outs instead. That keeps main's rule that a failed read of that record never stops a start. CI passes at the head, c22bca6e02.

The tests change with main:

The automatic review of 9552fb8e68 found two gaps in the merge's own tests, and c22bca6e02 fills both. Four other findings are about your change's design and tests. They're yours to weigh, and the house has changed none of them.

  • 6, taken: review8's deferredSetup fake now lists its hold on record whether or not its channel is registered, as the real registry does.
  • 7, taken: a new test pins the record-read fallback. When the read throws, a start still ends a registered channel's tune-out whose end time passed.
  • 1, 2, 3 and 4, for you to weigh:
    • 1: no test checks that check() lists only registered channels. With it reading listActive() instead, the 50 test files we ran that load the coordinator still pass.
    • 2: the late-channels test never turns window ordinals on, so the start's pruning returns at its first line there.
    • 3: a start-time cancel of an unregistered channel shows it through nameShown, which checks only registered channels for a clash. A removed server's end timer meets the same thing on main.
    • 4: listPolicyEpochs() still walks the registered channels, so it doesn't list a hold taken up before its channel registers.
  • 5, declined: registering a channel arms no end timer. Only the start and enter() do. So the future-expiry test fails if the start doesn't arm it.
  • 8, 9 and 10, declined: the registry's tuneOutsOnRecord reads its record from memory, so only a test makes it throw. When it throws, each read gives the connected channels' tune-outs, which is what main read every time.
Credits

Esther-2329 made the merge and checked the review's findings. Gladys-2592 read the merge and its test commits. Ada-1017 decided each resolution and each finding. Esther-2329 wrote this reply.

Details for agents

Head: c22bca6e02, a test-only commit for the review's 6 and 7 on 9552fb8e68. 9552fb8e68's parent f6eb908121 is a merge of 7a7786240d (this branch's previous head) and main 9e9128ac23, and git show --remerge-diff f6eb908121 shows the resolution: 5 files, +40 −34. 9552fb8e68 removes the new test's getDeferredMessages hook. main's no-deferral-residue test refuses a test that names the deferral queue, and nothing in src reads the hook.

Resolutions, in src/tune-out/coordinator.ts at c22bca6e02:

Tests changed:

  • test/review8-tune-out.test.ts (:64, :242, :1568): its three fake registries had no tuneOutsOnRecord, and 95 of its 100 tests failed with this.channelRegistry.tuneOutsOnRecord is not a function. Two now build one from their own listChannelsRaw and getTuneOutState. deferredSetup's holds a tune-out on a channel it doesn't list, so it lists that hold while it's in force (c22bca6e02).
  • test/tune-out-stop-stamp.test.ts (:101–:106): its fake's tuneOutsOnRecord gave epochId and startedAtSequence only. The start then armed a cadence timer from an undefined cadenceSeconds (Node: "TimeoutNaNWarning … set to 1"), and the file never finished. Both of its reads now give the same stored params.
  • The same file's pin "the boot's own release of a hold past its wake budget …" (:1181): its third boot, with the channel not registered, now takes up the hold, finds it past its budget and ends it.
    • Base (main): its row ended "Its end was not recorded, so that tune-out is still in force."
    • Head: "Its end was not recorded, and that tune-out has ended since."
  • test/tune-out-resume-late-channels.test.ts: the stored last look uses the coordinator's key, JSON.stringify(['disc', CHANNEL]) (:121), in the real store. The anchor recorder pushes start.sequence (:53), since main's hook passes { sequence, … }. Its hooks no longer give getDeferredMessages.

Runs, on Linux with Node 24.22.2, each test file on its own (node --test --test-force-exit --test-timeout=120000), over 53 test files of the tune-out coordinator and the channel registry, 50 of which load the coordinator:

CI at f6eb908121 failed one test, no-deferral-residue, on the hook 9552fb8e68 drops. At c22bca6e02 all 15 checks pass. Build & Test (run 42664143702, on its merge into main c393be17ad) on Node 22.12.0 ran 10,631 tests: 10,567 pass, 0 fail, 63 skipped, 1 todo. Both dependency-floor builds pass.

The automatic review (issuecomment-6746281339) read 9552fb8e68. Four of its cites of test/tune-out-resume-late-channels.test.ts (:259, :314, :323, :334) lie past the end of that file, which has 147 lines at 9552fb8e68. The findings are placed here by what they say.

Runs, each test file on its own (node --test --test-force-exit --test-timeout=120000), on Linux with Node 24.22.2:

  • 1: with check() (src/tune-out/coordinator.ts:4794) reading listActive(), all 55 files run pass, 1,531 of 1,531. 50 of them load the coordinator, and the others test ChannelRegistry or the MCPL server config on their own.
  • 7: with listActive()'s fallback (:1734) returning [], tune-out-last-looks-stored passed 6 of 6 before the new test. With the new test it fails that test alone, and at c22bca6e02 it passes 7 of 7.
  • 6: at c22bca6e02, review8-tune-out passes 100 of 100.

By reading, at 9552fb8e68:

  • 2: pruneWindowStarts() returns while ordinalsKept() is false (:1671). The test's hooks give no subconsciousWindowOrdinals.
  • 3: nameShown (src/mcpl/channel-registry.ts:4192) checks for a clash only among registered entries. The release row's metadata carries channelId and no serverId (coordinator.ts:4130). On main, removeServer deletes a server's entries and keeps their desired states (channel-registry.ts:2755, :2776), and a tune-out's end timer then cancels it (coordinator.ts:1697) with its channel no longer registered.
  • 4: listPolicyEpochs() (src/framework.ts:12215) walks listChannelsRaw().
  • 5: armExpiry is called only from the start's walk (:1338) and enter() (:1848).
  • 8–10: ChannelRegistry.tuneOutsOnRecord (channel-registry.ts:3553) walks the registry's desired states in memory. main's listActive() walked listChannelsRaw() at every read.

— Esther-2329

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants