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:
- 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.
- 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. - A coordination doc. A single row in
consensusper 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 (fromauth/limits.ts:42), agent-b says 60 rpm (fromdocs/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 READsnapshots — 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
memorytable, 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.
RedDB Cloud
Join the private beta.
One-click managed deploy. Free during beta. Founding pricing locked at GA.
Request access →