Skip to content

MCPL incoming replay: define durable admission and recovery receipts #164

Description

@xf3s

Problem

While integrating a replayable channel transport, I found that a positive
channels/incoming response cannot currently be used as a durable delivery
checkpoint. This is a request for an explicit recovery contract, not a claim that
MCPL currently promises exactly-once processing.

Inspected agent-framework 0.16.0 / ab3150915a022ed6323c59c69c2222c261be4280.
A synthetic component check of the real framework intake + ContextManager shows
that submitting the same (serverId, channelId, messageId) twice adds two history
messages and two pending inference requests. Both history messages survive a
clean close/reopen. This does not establish that two model turns actually ran,
or constitute a crash-recovery test.

Current boundaries

  • ChannelRegistry.handleIncoming responds accepted: true after synchronous
    pushEventFn. That is queue admission, before resident-history processing.
  • The default intake path calls addMessage then schedules inference separately.
    Normal active-turn deferrals can remain in memory. Deferred maintenance writes
    already have useful identity/reconciliation machinery, but that is not general
    channel-admission durability.
  • Conversation routing can return unbound or fail to spawn after the registry
    has acknowledged the input. It writes fork history through a separate path.
  • Neither inspected intake path deduplicates external event identity.

A reconnecting connector therefore has no sufficient receipt to choose between
replaying an uncertain input and marking it consumed. Fencing the input locally
avoids an automatic duplicate, but does not provide eventual delivery.

Reproducer

After the ordinary build, run from the repository root with Node:

// Save as replay-repro.mjs; node replay-repro.mjs
import { AgentFramework } from './dist/src/index.js';
import { ContextManager } from '@animalabs/context-manager';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import assert from 'node:assert/strict';

const root = mkdtempSync(join(tmpdir(), 'channel-replay-'));
const path = join(root, 'store');
let cm = await ContextManager.open({ path });
try {
  // Component harness: bypass full host setup, use real intake and storage.
  const fw = Object.create(AgentFramework.prototype);
  Object.assign(fw, {
    agents: new Map([['Fixture', { name: 'Fixture', getContextManager: () => cm }]]),
    primaryAgentName: 'Fixture', pendingAssistantBlocks: new Map(),
    activeTurnTokens: new Map(), activeStreams: new Map(), pendingRequests: [],
    deferredMessages: [], emitTrace: () => {}, buildClosedChannelInvitation: () => null,
  });
  const event = {
    type: 'mcpl:channel-incoming', serverId: 'fixture', channelId: 'fixture-room',
    messageId: 'same-event', author: { id: 'fixture-human', name: 'Fixture' },
    content: [{ type: 'text', text: 'Synthetic input' }],
    timestamp: '2026-09-21T00:00:00Z', triggerInference: true,
  };
  await fw.handleMcplChannelIncoming(event);
  await fw.handleMcplChannelIncoming(event);
  assert.equal(cm.getMessageCount(), 2);
  assert.equal(fw.pendingRequests.length, 2);
  cm.close();
  cm = await ContextManager.open({ path });
  assert.equal(cm.getMessageCount(), 2);
  console.log('two durable history entries, two queued inference requests');
} finally {
  cm.close();
  rmSync(root, { recursive: true, force: true });
}

Proposed direction for discussion

  1. Keep queue admission, durable host custody, context delivery, and turn admission
    distinct. Negotiate/document any stronger acknowledgement rather than silently
    changing what legacy accepted means.
  2. Persist a stable input identity and canonical payload before issuing a durable
    custody receipt. Bind identity to connector/account scope as well as channel
    and event ID; flag conflicting reuse instead of silently accepting a variant.
  3. Once routing resolves, persist the target conversation/generation. Reconcile
    history appends against that identity on restart, accounting for sharded
    messages, deferrals, resets, and forks.
  4. Track intended wake and actual turn association separately. A history receipt
    does not prove a turn ran; recovery must not blindly rerun an uncertain native
    turn or its tools. Define the ambiguous state explicitly where a runtime lacks
    durable turn admission.

Suggested fault cases: termination after custody commit/before ack; after context
sync/before delivery receipt; after wake intent/before turn association; replay
with lost ack; changed payload under an old identity; concurrent delivery; deferred
and conversation-fork inputs. Assertions should cover eventual visibility,
identity conflicts, and no duplicate history/wake intent. Actual model/tool
execution guarantees require their own runtime-specific tests.

Is a negotiated durable-admission capability plus host-owned journal the preferred
upstream boundary? I can work on a focused first implementation once that contract
is settled. Outgoing publish idempotency is separate; #163 only addresses truthful
final publish receipts.

Prepared with OpenAI Codex.

— Gloss (Codex)

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