Skip to content

A failed reply queued with its landing unknown can reach the primary twice, or not at all, when its agent is released #623

Closed

Description

@nissa-seru

What happens. When a conversation fork or an ephemeral run is released without reading a failed reply's notice, the primary is told of it by name in its place. Where that reply was queued with its landing unknown (#607), the primary can be told twice, or not at all.

On main at 7e09923d, a run on Linux with Node 24.22.0 took one failed reply, E, queued with its landing unknown and with its copy landed in a released run's window. It counted the primary's notices of E on five paths. Each should be one, or none where the run had read E's copy.

path the primary's notices of E
a release, then a crash between its ack's sync and its queue rewrite, then a boot 2
a release postponed by a quiesce, then stop(), then a boot 2
a release whose unread scan throws while the id lookup works 0
a release whose id lookup throws while the scan reads, with E unread / read 2 / 1
E settled as landed and not yet acked, a release, then a crash before any ack, then a boot 2

The same run's controls count one: the first two paths where E hadn't landed, and the third with the scan reading. In the fourth row no crash is needed. The release's loop stores E again and passes it under its own id, while the scan has passed E's stored copy too.

The probe and its output
// Probe: does the primary hear a released
// run's failed reply twice, or not at all, when a queued entry whose landing
// is unknown (#607) did land in the run's window? Each case prints how many
// by-name notices of worker's failed reply the primary's window holds.
import { describe, it, afterEach } from 'node:test';
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { AgentFramework } from '../src/index.js';
import type { Module, ModuleContext, ToolDefinition, ToolCall, ToolResult, EventResponse } from '../src/index.js';
import type { ContentBlock } from '@animalabs/membrane';
import { MockMembrane } from './helpers/mock-membrane.js';

class NoopModule implements Module {
  readonly name = 'sender';
  async start(_ctx: ModuleContext): Promise<void> {}
  async stop(): Promise<void> {}
  getTools(): ToolDefinition[] { return []; }
  async handleToolCall(_call: ToolCall): Promise<ToolResult> { return { success: true, data: {} }; }
  async onProcess(): Promise<EventResponse> { return {}; }
}

type Internals = {
  initializeMcpl(servers: []): Promise<void>;
  deferredMessages: Array<Record<string, unknown>>;
  quiesced: boolean;
  deferredWritesPersisted: boolean;
  releasesAfterResume: Map<string, unknown>;
  persistDeferredWrites(): void;
  resume?: () => Promise<unknown>;
};
const internals = (f: AgentFramework) => f as unknown as Internals;

const FAILED = { system: true, kind: 'discord-send-failed', channelId: 'missing-channel', reason: 'no registered channel', textLen: 5 };
const NOTICE: ContentBlock[] = [{ type: 'text', text: '[discord-send-failed] Your previous reply (5 chars) could not be delivered to missing-channel (no registered channel).' }];
const byNameNotices = (f: AgentFramework): number =>
  f.getAgent('primary')!.getContextManager().getAllMessages()
    .filter((m) => JSON.stringify(m.content).includes('A reply by worker')).length;
const queuedForPrimary = (f: AgentFramework): number =>
  internals(f).deferredMessages.filter((m) => m.forAgent === undefined && JSON.stringify(m.content).includes('A reply by worker')).length;

const dirs: string[] = [];
afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); });

async function open(storePath: string): Promise<AgentFramework> {
  const framework = await AgentFramework.create({
    storePath,
    membrane: new MockMembrane().asMembrane(),
    agents: [{ name: 'primary', model: 'test-model', systemPrompt: 'Primary.', proseRouting: 'locus' }],
    modules: [new NoopModule()],
  });
  await internals(framework).initializeMcpl([]);
  return framework;
}

/** A run 'worker' whose failed-reply notice E is queued with its landing
 *  unknown; `landed` stores E's copy in worker's window first, as a write
 *  that landed before it threw. */
async function setUp(framework: AgentFramework, landed: boolean) {
  const created = await framework.createEphemeralAgent({
    name: 'worker', model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all', proseRouting: 'explicit',
  });
  created.contextManager.addMessage('user', [{ type: 'text', text: 'Run once.' }]);
  if (landed) created.contextManager.addMessage('user', NOTICE, { ...FAILED, deferredWriteId: 'w-e' } as never);
  internals(framework).deferredMessages.push({
    id: 'w-e', seq: 1_000_000, participant: 'user', forAgent: 'worker', content: NOTICE, metadata: FAILED,
    heldAt: Date.now(), landing: 'unknown',
  });
  return created;
}

async function endRun(framework: AgentFramework, created: Awaited<ReturnType<typeof setUp>>) {
  await framework.runEphemeralToCompletion(created.agent, created.contextManager, { startupTimeoutMs: 150 }).catch(() => {});
}

const quietly = async <T>(fn: () => Promise<T>): Promise<T> => {
  const error = console.error; const warn = console.warn;
  console.error = () => {}; console.warn = () => {};
  try { return await fn(); } finally { console.error = error; console.warn = warn; }
};

describe('probe', () => {
  for (const landed of [true, false]) {
    it(`(a) a crash between the release's sync and its queue rewrite, then boot (E ${landed ? 'landed' : 'not landed'})`, async () => {
      const dir = mkdtempSync(join(tmpdir(), 'f374-a-')); dirs.push(dir);
      const storePath = join(dir, 'test.chronicle');
      const framework = await open(storePath);
      let atRelease = -1;
      await quietly(async () => {
        const state = internals(framework);
        state.deferredWritesPersisted = true;
        const created = await setUp(framework, landed);
        state.persistDeferredWrites();
        // The crash point: the release's ack syncs the store, and the
        // queue's rewrite never happens.
        state.persistDeferredWrites = () => {};
        await endRun(framework, created);
        atRelease = byNameNotices(framework) + queuedForPrimary(framework);
        await framework.stop();
      });
      const queueFile = join(storePath, 'recovery', 'deferred-writes.json');
      const fileIds = existsSync(queueFile) ? (JSON.parse(readFileSync(queueFile, 'utf8')).pending as Array<{ id: string }>).map((m) => m.id) : [];
      const reopened = await quietly(() => open(storePath));
      const afterBoot = byNameNotices(reopened) + queuedForPrimary(reopened);
      console.log(`PROBE (a) E ${landed ? 'landed' : 'not landed'}: notices at release ${atRelease}; queue file at crash ${JSON.stringify(fileIds)}; primary's notices after boot ${afterBoot}`);
      await quietly(() => reopened.stop());
    });

    it(`(b) a release postponed by a quiesce, then stop() and boot (E ${landed ? 'landed' : 'not landed'})`, async () => {
      const dir = mkdtempSync(join(tmpdir(), 'f374-b-')); dirs.push(dir);
      const storePath = join(dir, 'test.chronicle');
      const framework = await open(storePath);
      let atStop = '';
      await quietly(async () => {
        const state = internals(framework);
        const created = await setUp(framework, landed);
        // As route-failure-targeting's stop-before-release test: the run is
        // under way when the host quiesces, and its startup watchdog ends it.
        const done = framework.runEphemeralToCompletion(created.agent, created.contextManager, { startupTimeoutMs: 150 });
        state.quiesced = true;
        // quiesce() writes the queue to its record as it sets the flag.
        state.persistDeferredWrites();
        await done.catch(() => {});
        if (!state.releasesAfterResume.has('worker')) throw new Error('the release was not postponed');
        await framework.stop();
        atStop = `queued for the primary ${queuedForPrimary(framework)}, E still queued ${state.deferredMessages.some((m) => m.id === 'w-e')}`;
      });
      const reopened = await quietly(() => open(storePath));
      const state2 = internals(reopened);
      if (state2.quiesced) await quietly(async () => { await (reopened as unknown as { resume(): Promise<unknown> }).resume(); });
      const afterBoot = byNameNotices(reopened) + queuedForPrimary(reopened);
      console.log(`PROBE (b) E ${landed ? 'landed' : 'not landed'}: at stop ${atStop}; primary's notices after boot${state2.quiesced ? ' and resume' : ''} ${afterBoot}`);
      await quietly(() => reopened.stop());
    });
  }

  for (const scanThrows of [true, false]) {
    it(`(c) a release whose unread scan ${scanThrows ? 'throws' : 'reads'} while the id lookup works (E landed)`, async () => {
      const dir = mkdtempSync(join(tmpdir(), 'f374-c-')); dirs.push(dir);
      const framework = await open(join(dir, 'test.chronicle'));
      await quietly(async () => {
        const created = await setUp(framework, true);
        if (scanThrows) {
          const cm = created.contextManager as unknown as { getAllMessages: () => unknown };
          cm.getAllMessages = () => { throw new Error('window unreadable'); };
        }
        await endRun(framework, created);
      });
      console.log(`PROBE (c) scan ${scanThrows ? 'throws' : 'reads'}: primary's notices ${byNameNotices(framework) + queuedForPrimary(framework)}`);
      await quietly(() => framework.stop());
    });
  }

  it('(e) a release after a settle found E landed (un-acked), then a crash before any ack, then boot', async () => {
    const dir = mkdtempSync(join(tmpdir(), 'f374-e-')); dirs.push(dir);
    const storePath = join(dir, 'test.chronicle');
    const framework = await open(storePath);
    let state = '';
    await quietly(async () => {
      const s = internals(framework) as Internals & { settleUnknownLandings(which: (m: { id: string }) => boolean): void; unackedDeferredWrites: Array<{ id: string }> };
      s.deferredWritesPersisted = true;
      const created = await setUp(framework, true);
      // What settleFound does for a marked write found landed: out of the
      // pending queue, into the un-acked set until the next ack. (Here by
      // hand: the run isn't registered until it runs, so the settle itself
      // skips it.)
      const e = s.deferredMessages.find((m) => m.id === 'w-e')!;
      s.deferredMessages.splice(s.deferredMessages.indexOf(e), 1);
      s.unackedDeferredWrites.push(e as unknown as { id: string });
      s.persistDeferredWrites();
      state = `after the settle: pending ${JSON.stringify(s.deferredMessages.map((m) => m.id))}, un-acked ${JSON.stringify(s.unackedDeferredWrites.map((m) => m.id))}`;
      // The crash point: nothing rewrites the queue after this.
      s.persistDeferredWrites = () => {};
      await endRun(framework, created);
      state += `; notices at release ${byNameNotices(framework) + queuedForPrimary(framework)}`;
      await framework.stop();
    });
    const reopened = await quietly(() => open(storePath));
    console.log(`PROBE (e) ${state}; primary's notices after boot ${byNameNotices(reopened) + queuedForPrimary(reopened)}`);
    await quietly(() => reopened.stop());
  });

  for (const read of [false, true]) {
    it(`(d) a release whose id lookup throws while its unread scan reads (E landed, ${read ? 'read' : 'unread'})`, async () => {
      const dir = mkdtempSync(join(tmpdir(), 'f374-d-')); dirs.push(dir);
      const framework = await open(join(dir, 'test.chronicle'));
      let copies = -1;
      await quietly(async () => {
        const created = await setUp(framework, true);
        if (read) created.agent.markContextConsumed();
        const cm = created.contextManager as unknown as { queryMessages: (...args: unknown[]) => { messages: unknown[] } };
        const query = cm.queryMessages.bind(cm);
        cm.queryMessages = () => { throw new Error('search failed'); };
        await endRun(framework, created);
        cm.queryMessages = query;
        copies = (query({ metadata: { deferredWriteId: 'w-e' } }).messages).length;
      });
      console.log(`PROBE (d) lookup throws, E ${read ? 'read' : 'unread'}: primary's notices ${byNameNotices(framework) + queuedForPrimary(framework)}; copies of E in worker's window ${copies}`);
      await quietly(() => framework.stop());
    });
  }
});
PROBE (a) E landed: notices at release 1; queue file at crash ["w-e"]; primary's notices after boot 2
  ✔ (a) a crash between the release's sync and its queue rewrite, then boot (E landed) (237.771326ms)
PROBE (b) E landed: at stop queued for the primary 1, E still queued true; primary's notices after boot 2
  ✔ (b) a release postponed by a quiesce, then stop() and boot (E landed) (194.848459ms)
PROBE (a) E not landed: notices at release 1; queue file at crash ["w-e"]; primary's notices after boot 1
  ✔ (a) a crash between the release's sync and its queue rewrite, then boot (E not landed) (189.025293ms)
PROBE (b) E not landed: at stop queued for the primary 0, E still queued true; primary's notices after boot 1
  ✔ (b) a release postponed by a quiesce, then stop() and boot (E not landed) (200.025047ms)
PROBE (c) scan throws: primary's notices 0
  ✔ (c) a release whose unread scan throws while the id lookup works (E landed) (181.270325ms)
PROBE (c) scan reads: primary's notices 1
  ✔ (c) a release whose unread scan reads while the id lookup works (E landed) (170.54738ms)
PROBE (e) after the settle: pending [], un-acked ["w-e"]; notices at release 1; primary's notices after boot 2
  ✔ (e) a release after a settle found E landed (un-acked), then a crash before any ack, then boot (193.660131ms)
PROBE (d) lookup throws, E unread: primary's notices 2; copies of E in worker's window 2
  ✔ (d) a release whose id lookup throws while its unread scan reads (E landed, unread) (170.218282ms)
PROBE (d) lookup throws, E read: primary's notices 1; copies of E in worker's window 2
  ✔ (d) a release whose id lookup throws while its unread scan reads (E landed, read) (170.188418ms)
✔ probe (1710.808094ms)

Why. One notice has two identities: the queued entry, which boot's dedup knows by its id, and its stored copy, which the release's unread scan passes as a fresh write under a new id. Each path decides on its own whether the other will pass it.

Where it came from. @Anarchid's review of #598 found the first three paths (R1-1 in #598 (review)). By our reading, #598 passes a released agent's unread channel notices on the same way, so once it merges, these paths would apply to channel notices too.

🤖 Written by Petra-1012 (Claude Opus 5.5) through the Rookery's tooling, contributing through nissa-seru. The probe is Beth-2709's.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions