ruvnet/ruflo · error · Error

LocalTransport: replayed/out-of-order seq from ${msg.from} (

Error message

LocalTransport: replayed/out-of-order seq from ${msg.from} (${msg.seq} <= ${last})

What it means

Part of LocalTransport's replay defense: when the target verifies signed messages, it tracks the highest seq seen per sender and requires strictly increasing values. This error means msg.seq is <= the last seq recorded from that sender — either a literal replay of an earlier message or an out-of-order/duplicate delivery.

Source

Thrown at v3/@claude-flow/swarm/src/consensus/transport.ts:247

    if (this.keyPair) {
      return { ...base, signature: signMessage(base, this.keyPair.privateKeyPem) };
    }
    return base;
  }

  /** Deliver an inbound message to a target's handler, with optional sig + replay checks. */
  private async deliver(target: LocalTransport, msg: ConsensusMessage): Promise<ConsensusReply> {
    if (target.closed) throw new Error(`LocalTransport: peer ${target.nodeId} is closed`);
    // Verification path — only when the *target* expects signed messages.
    if (target.keyPair && target.resolvePeerPublicKey) {
      const pub = target.resolvePeerPublicKey(msg.from);
      if (!pub || !verifyMessage(msg, pub)) {
        throw new Error(`LocalTransport: signature verification failed for message from ${msg.from}`);
      }
      // Replay defense: seq must be strictly increasing per sender.
      if (typeof msg.seq === 'number') {
        const last = target.lastSeenSeq.get(msg.from) ?? 0;
        if (msg.seq <= last) throw new Error(`LocalTransport: replayed/out-of-order seq from ${msg.from} (${msg.seq} <= ${last})`);
        target.lastSeenSeq.set(msg.from, msg.seq);
      }
    }
    if (!target.handler) return null;
    const reply = await target.handler(msg);
    return (reply ?? null) as ConsensusReply;
  }

  async send(to: string, msg: Omit<ConsensusMessage, 'from'>, timeoutMs?: number): Promise<ConsensusReply> {
    if (this.closed) throw new Error('LocalTransport: closed');
    const target = this.registry.get(to);
    if (!target) throw new Error(`LocalTransport: unreachable peer ${to}`);
    const stamped = this.stamp(msg);
    const t = timeoutMs ?? this.defaultTimeoutMs;
    return Promise.race([
      this.deliver(target, stamped),
      new Promise<ConsensusReply>((_, rej) => setTimeout(() => rej(new Error(`LocalTransport: send to ${to} timed out (${t}ms)`)), t)),
    ]);

View on GitHub (pinned to fa13ee4ad6)

Solutions

  1. Always let the transport stamp fresh messages (construct a new message object per send; never cache the stamped result for retry)
  2. When re-creating a sender node with the same nodeId, use a fresh LocalTransportRegistry or a new nodeId so per-sender seq state resets together
  3. If you must set seq manually on unsigned senders, keep your own monotonic counter per sender
  4. On this error, drop the duplicate message rather than crashing the handler — it indicates dedup working as designed

Example fix

// before (retry replays the stamped message -> seq replay)
const stamped = { type: 'gossip', seq: 7, payload: {...}, from: 'a' };
await tx.send('b', stamped).catch(() => tx.send('b', stamped)); // second call: 7 <= 7

// after (retry re-stamps; transport's seqCounter keeps increasing)
const build = () => ({ type: 'gossip', payload: {...} }); // no manual seq
try { await tx.send('b', build()); }
catch { await tx.send('b', build()); } // new seq each attempt
Defensive patterns

Strategy: try-catch

Validate before calling

// Do not hand-craft seq; let the transport stamp it. If you must pre-check duplicates:
const digest = messageDigest(msg); // stable sha256 of content
if (seenDigests.has(digest)) skip(); else seenDigests.add(digest);

Try / catch

try { await transport.send(to, msg); }
catch (e) {
  if (e instanceof Error && /replayed\/out-of-order seq/.test(e.message)) {
    return; // duplicate delivery — drop silently, this is dedup working
  }
  throw e;
}

Prevention

When it happens

Trigger: (1) Re-delivering the exact same stamped message object twice (e.g., application-level retry that caches the stamped message instead of re-stamping). (2) Manually supplying msg.seq with a repeated or lower number — note stamp() only overrides seq when the sender has a keyPair, so manual seq values survive from unsigned senders. (3) Sequential restarts reusing a sender nodeId without resetting the target's lastSeenSeq (fresh sender seqCounter starts at 1 while target remembers a higher value).

Common situations: Application retry logic around transport.send() that captures and replays the outbound message Test suites reusing a registry across cases while constructing new transports with the same nodeIds Protocols that attach their own sequence numbers colliding with the transport's replay counter Reconstructing a 'resumed' node with the same identity but a reset seqCounter

Related errors


AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18). Data as JSON: /api/errors/621d9d5d54bdddfb. Report an issue: GitHub.