Agents · 2026-05-30 · By RedDB team · 4 min read

Multi-agent shared memory — five agents, one knowledge graph

When parallel sub-agents work on related slices of the same problem, they need a shared scratchpad with conflict resolution. A snapshot-read, first-committer-wins write pattern over RedDB gives each agent isolation while still surfacing contradictions as alerts on the orchestrator side.

Why parallel agents need a shared scratchpad

Spawning five Claude Code sub-agents in parallel is easy. Letting them cooperate is not. Each sub-agent has its own context, its own tool budget, and no idea what its siblings just decided. Without a coordination layer you get three failure modes within the first hour:

  • Duplicate work. Two agents independently re-derive that the auth middleware uses JWT.
  • Contradictory facts. Agent A writes “rate limit is 100 rpm”. Agent B, five minutes later, writes “rate limit is 60 rpm”. The orchestrator merges both and the parent session uses whichever sorts last.
  • Lost decisions. Agent C decides to switch the queue from SQS to Redis. Nobody else hears about it until the integration step blows up.

A shared knowledge graph in RedDB — with snapshot reads and first-committer-wins writes — fixes all three. Reuses the memory table from the memory-backend post almost verbatim, plus one extra column for the agent that wrote the row and one extra table for the coordination doc.

The shape of the fix

Three primitives:

  1. Snapshot reads. Each sub-agent reads under a fixed transaction snapshot. Whatever was committed when the agent started is what the agent sees, even if siblings keep writing.
  2. First-committer-wins writes. Writes to the same (scope, key) are rejected for any agent that did not see the latest version. The losing agent gets the conflict back as a structured error and decides what to do — retry, escalate, or merge.
  3. A coordination doc. A single row in consensus per topic, pointing at the latest agreed value. The orchestrator subscribes to changes and surfaces contradictions.

Schema

-- Reuse the memory table from the D1 post, add the writer.
ALTER TABLE memory ADD COLUMN written_by TEXT NOT NULL DEFAULT 'human';
ALTER TABLE memory ADD COLUMN scope_key  TEXT;                -- e.g. "auth.rate_limit"
ALTER TABLE memory ADD COLUMN version    BIGINT NOT NULL DEFAULT 1;

CREATE UNIQUE INDEX memory_scope_key_version
  ON memory (scope_key, version)
  WHERE scope_key IS NOT NULL;

-- One row per topic. Points at the memory row currently considered "true".
CREATE TABLE consensus (
  scope_key   TEXT PRIMARY KEY,
  memory_id   TEXT NOT NULL REFERENCES memory(id),
  agreed_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  agreed_by   TEXT NOT NULL,                -- orchestrator id
  version     BIGINT NOT NULL
);

-- Audit trail of every conflict so the orchestrator can replay them.
CREATE TABLE conflict (
  id          TEXT PRIMARY KEY,
  scope_key   TEXT NOT NULL,
  loser_id    TEXT NOT NULL REFERENCES memory(id),
  winner_id   TEXT NOT NULL REFERENCES memory(id),
  detected_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

The version column is the optimistic-concurrency token. The unique index over (scope_key, version) is what makes “first committer wins” enforceable in one round-trip: the second writer’s INSERT fails with a unique-violation, no advisory locks, no SELECT ... FOR UPDATE.

The write path

Each sub-agent runs the same compare-and-set:

WITH latest AS (
  SELECT version FROM memory
   WHERE scope_key = $1
   ORDER BY version DESC
   LIMIT 1
)
INSERT INTO memory (id, kind, scope, scope_key, body, embedding, written_by, version)
SELECT $2, 'fact', $3, $1, $4, $5, $6, COALESCE((SELECT version FROM latest), 0) + 1
RETURNING id, version;

If two agents fire this query concurrently with the same scope_key, both compute version = N+1 and both try to insert. The unique index lets exactly one win. The other gets 23505 unique_violation back, which the agent SDK should map to a typed ConflictError carrying the winning row’s id.

Code (Node, intentionally boring):

async function writeFact(scopeKey: string, body: string, agentId: string) {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const { rows } = await pg.query(WRITE_FACT_SQL, [
        scopeKey,
        ulid(),
        'agent:' + agentId,
        body,
        await embed(body),
        agentId,
      ])
      return { ok: true, id: rows[0].id, version: rows[0].version }
    } catch (err) {
      if (err.code !== '23505') throw err
      // Lost the race. Read the current truth and decide.
      const current = await readConsensus(scopeKey)
      if (current.body === body) return { ok: true, id: current.id, version: current.version }
      return { ok: false, conflict: current, attempted: body }
    }
  }
  throw new Error('writeFact: exhausted retries')
}

The ok: false branch is the interesting one. The losing agent now has both the consensus value and its own attempted value in hand, and has to choose:

  • Identical body → silently accept, no-op. (Same conclusion reached twice — common with parallel research agents.)
  • Different body → record a conflict and either escalate to the orchestrator or attempt a merge.

The read path

Each sub-agent reads under a snapshot transaction so that mid-task writes by siblings do not change what the agent already reasoned about:

async function readScope(scope: string) {
  const client = await pg.connect()
  try {
    await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ')
    const memories = await client.query(
      `SELECT m.id, m.scope_key, m.body, m.version, m.written_by
         FROM memory m
         JOIN consensus c ON c.memory_id = m.id
        WHERE m.scope = $1`,
      [scope],
    )
    await client.query('COMMIT')
    return memories.rows
  } finally {
    client.release()
  }
}

REPEATABLE READ means subsequent reads in the same transaction see the same snapshot. For a long-running agent task that mixes reads and tool calls, opening one snapshot at the start and closing it at the end is the cleanest model — every fact the agent acts on is internally consistent, even if the world moved on.

Recording conflicts and alerting the orchestrator

The losing-write path inserts a conflict row. The orchestrator subscribes to LISTEN conflict_inserted (Postgres NOTIFY triggered from a trivial AFTER INSERT trigger) and gets a structured event the moment two agents disagree:

CREATE OR REPLACE FUNCTION notify_conflict() RETURNS trigger AS $$
BEGIN
  PERFORM pg_notify('conflict_inserted', json_build_object(
    'scope_key', NEW.scope_key,
    'loser_id',  NEW.loser_id,
    'winner_id', NEW.winner_id
  )::text);
  RETURN NEW;
END $$ LANGUAGE plpgsql;

CREATE TRIGGER conflict_inserted_notify
AFTER INSERT ON conflict
FOR EACH ROW EXECUTE FUNCTION notify_conflict();

The orchestrator-side listener is ten lines:

const client = await pg.connect()
await client.query('LISTEN conflict_inserted')
client.on('notification', (msg) => {
  const { scope_key, loser_id, winner_id } = JSON.parse(msg.payload!)
  orchestrator.alert({
    kind: 'fact_conflict',
    topic: scope_key,
    candidates: [loser_id, winner_id],
  })
})

In Claude Code the orchestrator is usually the parent session that spawned the sub-agents via the Task tool. Surface the alert as a SessionStart-style banner in the next turn so the human (or the parent agent) decides which fact to promote.

Promoting consensus

When the parent picks a winner — manually or by rule (most-recent, highest-confidence, majority-vote) — promotion is one row update:

INSERT INTO consensus (scope_key, memory_id, agreed_by, version)
VALUES ($1, $2, $3, $4)
ON CONFLICT (scope_key) DO UPDATE
   SET memory_id = EXCLUDED.memory_id,
       agreed_by = EXCLUDED.agreed_by,
       version   = EXCLUDED.version,
       agreed_at = now()
 WHERE consensus.version < EXCLUDED.version;

The WHERE consensus.version < EXCLUDED.version guard means an out-of-order promotion (rare, but possible if the orchestrator itself parallelises) never rolls consensus backwards.

Worked example

Three sub-agents auditing a service. The orchestrator spawns them in parallel:

agent-a: read auth/* → fact "rate_limit = 100 rpm"  → INSERT version 1 ✓
agent-b: read docs/*  → fact "rate_limit = 60 rpm"   → INSERT version 1 ✗ (unique violation)
                                                      → reads consensus (100 rpm)
                                                      → bodies differ, INSERT conflict
agent-c: read tests/* → fact "rate_limit = 100 rpm"  → bodies match, no-op

The orchestrator’s listener fires once, receives {scope_key: "auth.rate_limit", loser: B, winner: A}, and the next turn of the parent session sees:

⚠️ Two sub-agents disagreed on auth.rate_limit. Candidate facts: agent-a says 100 rpm (from auth/limits.ts:42), agent-b says 60 rpm (from docs/api.md:88). Promote one?

The human (or a /promote slash command) picks one, and the consensus row updates. The other fact stays in memory for audit — nothing is discarded, only de-promoted.

What this gives you

  • Isolation per agent via REPEATABLE READ snapshots — no torn reads, no flapping facts mid-task.
  • One-shot conflict detection via the (scope_key, version) unique index — no advisory locks, no leader election.
  • Append-only history — every losing write is still in the memory table, queryable for “when did we change our mind about X?“.
  • Push-based orchestrator alerts via LISTEN/NOTIFY — no polling, conflicts surface within milliseconds.

The whole thing is roughly 150 lines of SQL + TypeScript and rides on the same RedDB instance already wired up for agent observability and the /remember command. When the next post in this pillar adds sub-agent dispatch via a database queue, the queue rows will simply point at scope keys here.

Five agents, one knowledge graph, no merge hell.

© 2026 RedDB.io. AGPL-3.0 self-host · Managed Cloud.