diff --git a/guide/sdk.md b/guide/sdk.md index 5553f1f8..56cef054 100644 --- a/guide/sdk.md +++ b/guide/sdk.md @@ -80,5 +80,7 @@ AGENTCOMM_BACKEND_PLUGINS=agentcomm-backend-redis agentcomm send bob hi --backen `AGENTCOMM_BACKEND_PLUGINS` is a comma/whitespace-separated list of module specifiers the CLI imports before resolving `--backend`. Implement -`Claimable`/`Waitable` too if the store can support atomic claims or push — the Bus -feature-detects both, no registration needed beyond `Backend` itself. +`Claimable`/`Waitable`/`Batchable` too if the store can support atomic claims, +push, or many moves in one operation — the Bus feature-detects all three, no +registration needed beyond `Backend` itself. `Batchable` is what keeps +consuming a full mailbox to a single round trip instead of one per message. diff --git a/src/backends/git.ts b/src/backends/git.ts index 67ad1a80..410258a9 100644 --- a/src/backends/git.ts +++ b/src/backends/git.ts @@ -3,7 +3,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { promises as fs } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { type Backend, type Claimable, type Message, type Snapshottable } from '../types.js'; +import { type Backend, type Batchable, type Claimable, type Message, type Snapshottable } from '../types.js'; /** * GitBackend — the generic "commits are the storage" transport, host-agnostic @@ -34,7 +34,15 @@ import { type Backend, type Claimable, type Message, type Snapshottable } from ' * - `claim` is implemented (Claimable) via optimistic CAS — race-free * shared work queues with zero infrastructure. */ -export class GitBackend implements Backend, Claimable, Snapshottable { +/** + * Cap on a single git invocation. A remote that accepts the connection and + * then goes quiet leaves `git` blocked forever, which reaches the caller as a + * command that simply never returns — indistinguishable from a deadlock + * (issue #159). A clear, attributable error is always better. + */ +const GIT_TIMEOUT_MS = Math.max(1000, Number(process.env.AGENTCOMM_GIT_TIMEOUT_MS ?? 120_000)); + +export class GitBackend implements Backend, Batchable, Claimable, Snapshottable { /** Each poll is a real fetch — cheap against local remotes, a round trip against hosts. */ readonly pollIntervalMs = 2000; @@ -98,13 +106,28 @@ export class GitBackend implements Backend, Claimable, Snapshottable { }); const out: Buffer[] = []; const err: Buffer[] = []; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill('SIGKILL'); + }, GIT_TIMEOUT_MS); + timer.unref?.(); child.stdout.on('data', (d: Buffer) => out.push(d)); child.stderr.on('data', (d: Buffer) => err.push(d)); - child.on('error', (e) => - reject(e.message.includes('ENOENT') ? new Error('agentcomm: the git+ backends need the `git` binary on PATH') : e), - ); + child.on('error', (e) => { + clearTimeout(timer); + reject(e.message.includes('ENOENT') ? new Error('agentcomm: the git+ backends need the `git` binary on PATH') : e); + }); child.on('close', (code) => { - if (code === 0) resolve(Buffer.concat(out)); + clearTimeout(timer); + if (timedOut) { + reject( + new Error( + `agentcomm: git ${args[0]} on ${this.remote} timed out after ${GIT_TIMEOUT_MS}ms ` + + '(unreachable or very slow remote; raise AGENTCOMM_GIT_TIMEOUT_MS if this is normal for your bus)', + ), + ); + } else if (code === 0) resolve(Buffer.concat(out)); else { const e = new Error( `agentcomm: git ${args[0]} failed (exit ${code}): ${Buffer.concat(err).toString('utf8').trim().slice(0, 400)}`, @@ -286,6 +309,37 @@ export class GitBackend implements Backend, Claimable, Snapshottable { } } + /** + * Archive a whole mailbox in ONE commit (issue #159). Consuming N messages + * key-by-key is N fetch→commit→push round trips — seconds each, and the + * command times out long before the last one lands. Batched, it is one. + */ + async moveMany(moves: { src: string; dst: string }[]): Promise { + if (moves.length === 0) return; + if (moves.length === 1) return this.move(moves[0]!.src, moves[0]!.dst); + for (let attempt = 1; attempt <= 6; attempt++) { + const tip = await this.tip(); + if (tip === null) throw notFound(moves[0]!.src); + const add: { key: string; blob: string }[] = []; + const remove: string[] = []; + for (const { src, dst } of moves) { + let blob: string; + try { + blob = (await this.git(['rev-parse', `${tip}:${this.k(src)}`])).toString('utf8').trim(); + } catch { + continue; // already moved by someone else — not this batch's problem + } + add.push({ key: dst, blob }); + remove.push(src); + } + if (remove.length === 0) return; + const message = `agentcomm: archive ${remove.length} message(s) [${randomUUID().slice(0, 8)}]`; + if (await this.commitAndPush(tip, message, { add, remove })) return; + await sleep(30 * attempt + Math.floor(Math.random() * 80)); + } + throw new Error(`agentcomm: git moveMany kept losing push races — extremely contended bus?`); + } + async move(src: string, dst: string): Promise { // One commit adds dst and removes src — push lands it atomically. for (let attempt = 1; attempt <= 6; attempt++) { diff --git a/src/backends/socket.ts b/src/backends/socket.ts index 44583a10..f8876e75 100644 --- a/src/backends/socket.ts +++ b/src/backends/socket.ts @@ -179,7 +179,12 @@ export class SocketBackend implements Backend { } async move(src: string, dst: string): Promise { - ok(await this.rpc.call('move', { src, dst })); + ok(await this.rpc.call('move', { src, dst, sync: this.syncWrites })); + } + + /** Archiving a mailbox is ONE call (issue #159), not one per message. */ + async moveMany(moves: { src: string; dst: string }[]): Promise { + ok(await this.rpc.call('moveMany', { moves, sync: this.syncWrites })); } async info(): Promise> { diff --git a/src/bus.ts b/src/bus.ts index 8266dc18..0bf444c9 100644 --- a/src/bus.ts +++ b/src/bus.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { isClaimable, isWaitable, type Backend, type Message } from './types.js'; +import { isBatchable, isClaimable, isWaitable, type Backend, type Message } from './types.js'; /** How long an explicit status stays sticky before a newer task can refresh it. */ const EXPLICIT_STICKY_MS = Number(process.env.AGENTCOMM_EXPLICIT_STICKY_MS ?? 15 * 60_000); @@ -181,6 +181,19 @@ export class Bus { * a store that went away mid-run; they stay pending and re-deliver. */ async archive(keys: string[]): Promise { + if (keys.length === 0) return []; + // One store operation for the whole mailbox where the backend can do it + // (issue #159): key-by-key, archiving a full inbox is a network round + // trip per message and the command times out before it finishes. + if (isBatchable(this.backend)) { + try { + await this.backend.moveMany(keys.map((key) => ({ src: key, dst: readKeyFromInboxKey(key) }))); + return []; + } catch { + // fall through: retry key-by-key, so a batch that failed as a whole + // still archives whatever it individually can + } + } const failed: string[] = []; for (const key of keys) { try { diff --git a/src/daemon.ts b/src/daemon.ts index 6ccdb3f2..845c553a 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -2,10 +2,11 @@ * The bus daemon: one background process per bus URI that polls the real * backend on its own clock and serves CLI processes over a unix socket. * - * Reads come from a warm mirror (staleness ≤ the poll interval); writes go - * through to the real backend immediately and update the mirror, so - * read-your-write always holds. `claim` is forwarded verbatim — atomicity - * must come from the store, never from a cache. + * Reads come from a warm mirror (staleness ≤ the poll interval); writes are + * accepted onto a durable disk outbox, applied to the mirror at once and + * delivered to the real store on the daemon's clock, so read-your-write + * always holds while no client waits out a remote round trip. `claim` is + * forwarded verbatim — atomicity must come from the store, never a cache. * * The socket binds BEFORE the first poll (issue #144): warming a large bus * key-by-key can take minutes on round-trip-per-read stores, and a daemon @@ -14,7 +15,7 @@ * block until the warm-up finishes. * * The protocol is newline-delimited JSON, the Backend interface verbatim: - * → {id, op: 'get'|'put'|'list'|'delete'|'exists'|'move'|'claim'|'info'|'stop', ...} + * → {id, op: 'get'|'put'|'list'|'delete'|'exists'|'move'|'moveMany'|'claim'|'info'|'stop', ...} * ← {id, ok: true, ...} | {id, ok: false, error, code?} * Buffers travel base64-encoded in `data`. */ @@ -24,7 +25,7 @@ import * as path from 'node:path'; import { promises as fs } from 'node:fs'; import { createHash } from 'node:crypto'; import { createBackend } from './backends/index.js'; -import { isClaimable, isSnapshottable, type Backend } from './types.js'; +import { isBatchable, isClaimable, isSnapshottable, type Backend } from './types.js'; export function daemonDir(): string { return process.env.AGENTCOMM_DAEMON_DIR ?? path.join(os.homedir(), '.cache', 'agentcomm', 'd'); @@ -40,6 +41,7 @@ interface Req { key?: string; src?: string; dst?: string; + moves?: { src: string; dst: string }[]; prefix?: string; queue?: string; owner?: string; @@ -47,6 +49,15 @@ interface Req { sync?: boolean; } +/** + * One deferred write in the outbox. `op` is absent on entries written by + * pre-0.21 daemons, which only ever spooled puts — treat those as puts. + */ +type SpoolEntry = + | { op?: 'put'; key: string; data: string } + | { op: 'move'; moves: { src: string; dst: string }[] } + | { op: 'delete'; key: string }; + export async function runDaemon(uri: string): Promise { // github:// pays REST quota per poll (5,000/hr shared) — default gently const defaultPollMs = uri.startsWith('github://') ? 30_000 : 10_000; @@ -86,17 +97,20 @@ export async function runDaemon(uri: string): Promise { }; if (await probePeer()) await bowOut(); - // Outbox spool: puts are accepted onto disk and delivered by the flusher, + // Outbox spool: writes are accepted onto disk and delivered by the flusher, // so `send` acks in milliseconds while delivery (a git push, an API call) // happens on the daemon's clock — FIFO, retried, surviving restarts. + // Consuming reads spool too (issue #159): archiving a mailbox key-by-key + // against a remote store is a round trip per message, which is what made + // `inbox` time out where `peek` was instant. const spoolDir = sockPath + '.spool'; await fs.mkdir(spoolDir, { recursive: true }); let spoolSeq = 0; let flushFailures = 0; - async function spoolAdd(key: string, data: Buffer): Promise { + async function spoolAdd(entry: SpoolEntry): Promise { const name = `${String(Date.now()).padStart(14, '0')}-${String(++spoolSeq).padStart(6, '0')}`; const tmp = path.join(spoolDir, '.' + name); - await fs.writeFile(tmp, JSON.stringify({ key, data: data.toString('base64') })); + await fs.writeFile(tmp, JSON.stringify(entry)); await fs.rename(tmp, path.join(spoolDir, name)); // atomic appearance } async function spoolDepth(): Promise { @@ -115,14 +129,31 @@ export async function runDaemon(uri: string): Promise { spoolChain = next.catch(() => {}); return next; } + /** Apply one outbox entry against the real store. */ + async function deliver(entry: SpoolEntry): Promise { + if (entry.op === 'delete') return backend.delete(entry.key); + if (entry.op === 'move') { + // One store operation where the backend can (a single git commit for a + // whole mailbox); otherwise pair by pair. A pair whose source is + // already gone was archived by someone else — not an error. + if (isBatchable(backend) && entry.moves.length > 1) return backend.moveMany(entry.moves); + for (const { src, dst } of entry.moves) { + await backend.move(src, dst).catch((err: NodeJS.ErrnoException) => { + if (err?.code !== 'ENOENT') throw err; + }); + } + return; + } + return backend.put(entry.key, Buffer.from(entry.data, 'base64')); + } + function flush(): Promise { return withSpool(async () => { const entries = (await fs.readdir(spoolDir)).filter((f) => !f.startsWith('.')).sort(); for (const f of entries) { const file = path.join(spoolDir, f); try { - const { key, data } = JSON.parse(await fs.readFile(file, 'utf8')) as { key: string; data: string }; - await backend.put(key, Buffer.from(data, 'base64')); + await deliver(JSON.parse(await fs.readFile(file, 'utf8')) as SpoolEntry); await fs.rm(file, { force: true }); flushFailures = 0; } catch { @@ -132,14 +163,14 @@ export async function runDaemon(uri: string): Promise { } }); } - /** If `key` is still spooled, rewrite/remove it locally and return true. */ + /** If `key` is a still-spooled PUT, rewrite/remove it locally and return true. */ function spoolTake(key: string, rewriteTo?: string): Promise { return withSpool(async () => { for (const f of (await fs.readdir(spoolDir)).filter((x) => !x.startsWith('.')).sort()) { const file = path.join(spoolDir, f); try { - const entry = JSON.parse(await fs.readFile(file, 'utf8')) as { key: string; data: string }; - if (entry.key !== key) continue; + const entry = JSON.parse(await fs.readFile(file, 'utf8')) as SpoolEntry; + if (entry.op === 'move' || entry.op === 'delete' || entry.key !== key) continue; if (rewriteTo) await fs.writeFile(file, JSON.stringify({ ...entry, key: rewriteTo })); else await fs.rm(file, { force: true }); return true; @@ -162,16 +193,13 @@ export async function runDaemon(uri: string): Promise { * other order can miss it mid-flush for a full poll cycle. Runs under the * flush mutex (issue #79): reading a spool file while the flusher rm's it * silently dropped the key for a cycle under load. */ - function snapshotSpool(): Promise { + function snapshotSpool(): Promise { return withSpool(async () => { - const spooled: string[] = []; + const spooled: SpoolEntry[] = []; try { - for (const f of (await fs.readdir(spoolDir)).filter((x) => !x.startsWith('.'))) { + for (const f of (await fs.readdir(spoolDir)).filter((x) => !x.startsWith('.')).sort()) { try { - const { key } = JSON.parse(await fs.readFile(path.join(spoolDir, f), 'utf8')) as { - key: string; - }; - spooled.push(key); + spooled.push(JSON.parse(await fs.readFile(path.join(spoolDir, f), 'utf8')) as SpoolEntry); } catch { /* entry mid-write */ } } } catch { /* spool gone (shutdown) */ } @@ -179,24 +207,53 @@ export async function runDaemon(uri: string): Promise { }); } + /** + * Replay the undelivered outbox over what the store currently says, in FIFO + * order. Without this a poll would resurrect keys an accepted-but-not-yet- + * flushed archive already consumed — the store still has them — and the + * message would be delivered twice (issue #159). + */ + function replaySpool(ops: SpoolEntry[], bodies: Map, present: Set): void { + for (const entry of ops) { + if (entry.op === 'delete') { + bodies.delete(entry.key); + present.delete(entry.key); + } else if (entry.op === 'move') { + for (const { src, dst } of entry.moves) { + const body = bodies.get(src) ?? mirror.get(src) ?? mirror.get(dst); + bodies.delete(src); + present.delete(src); + if (body) bodies.set(dst, body); + present.add(dst); + } + } else { + // spooled but not delivered yet — keep the local body + const body = bodies.get(entry.key) ?? mirror.get(entry.key) ?? Buffer.from(entry.data, 'base64'); + bodies.set(entry.key, body); + present.add(entry.key); + } + } + } + async function doPoll(): Promise { const spooled = await snapshotSpool(); if (isSnapshottable(backend)) { const snap = await backend.snapshot(''); - for (const k of spooled) { - const body = snap.get(k) ?? mirror.get(k); - if (body) snap.set(k, body); // spooled but not delivered yet — keep the local body - } + const present = new Set(snap.keys()); + replaySpool(spooled, snap, present); + for (const k of snap.keys()) if (!present.has(k)) snap.delete(k); mirror.clear(); for (const [k, v] of snap) mirror.set(k, v); - keys = new Set([...snap.keys(), ...spooled]); + keys = present; return; } - const listed = await backend.list(''); - const next = new Set([...listed, ...spooled]); + const bodies = new Map(); + const next = new Set(await backend.list('')); + replaySpool(spooled, bodies, next); for (const k of mirror.keys()) { if (!next.has(k) || k.startsWith('agents/')) mirror.delete(k); } + for (const [k, v] of bodies) if (next.has(k)) mirror.set(k, v); keys = next; } // Coalesce: timer ticks, `refresh`, and post-claim polls that overlap a @@ -262,29 +319,44 @@ export async function runDaemon(uri: string): Promise { if (req.sync) { await backend.put(req.key!, buf); } else { - await spoolAdd(req.key!, buf); // durable locally; flusher delivers + await spoolAdd({ op: 'put', key: req.key!, data: buf.toString('base64') }); // durable locally; flusher delivers } mirror.set(req.key!, buf); keys.add(req.key!); return { ok: true, queued: !req.sync }; } case 'delete': - if (!(await spoolTake(req.key!))) await backend.delete(req.key!); + if (!(await spoolTake(req.key!))) { + if (req.sync) await backend.delete(req.key!); + else await spoolAdd({ op: 'delete', key: req.key! }); + } mirror.delete(req.key!); keys.delete(req.key!); return { ok: true }; - case 'move': { - if (!(await spoolTake(req.src!, req.dst!))) { - await backend.move(req.src!, req.dst!); + case 'move': + case 'moveMany': { + // Consuming a mailbox acks from the outbox, exactly like a send + // (issue #159): the client is not held for a remote round trip per + // archived message. The mirror moves the keys now, and the poll + // replays the undelivered outbox so the store's stale view cannot + // resurrect them. + const moves = req.op === 'move' ? [{ src: req.src!, dst: req.dst! }] : (req.moves ?? []); + const deferred: { src: string; dst: string }[] = []; + for (const { src, dst } of moves) { + if (!(await spoolTake(src, dst))) deferred.push({ src, dst }); } - const body = mirror.get(req.src!); - mirror.delete(req.src!); - keys.delete(req.src!); - if (body) { - mirror.set(req.dst!, body); - keys.add(req.dst!); + if (deferred.length > 0) { + if (req.sync) await deliver({ op: 'move', moves: deferred }); + else await spoolAdd({ op: 'move', moves: deferred }); } - return { ok: true }; + for (const { src, dst } of moves) { + const body = mirror.get(src); + mirror.delete(src); + keys.delete(src); + if (body) mirror.set(dst, body); + keys.add(dst); + } + return { ok: true, queued: !req.sync }; } case 'claim': { if (!claimable) return { ok: false, code: 'ENOTSUP', error: 'backend does not support claim' }; diff --git a/src/types.ts b/src/types.ts index b864e2fa..0c12b1e2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -77,6 +77,18 @@ export interface Snapshottable { snapshot(prefix: string): Promise>; } +/** + * Optional capability: apply many moves as ONE store operation. Consuming a + * mailbox archives every message it delivered; key-by-key that is a network + * round trip per message (a git commit+push each), which is what made `inbox` + * time out where `peek` was instant (issue #159). Backends that can commit a + * batch declare it here; the Bus falls back to per-key moves without it. + */ +export interface Batchable { + /** Move every pair, atomically where the store allows it. */ + moveMany(moves: { src: string; dst: string }[]): Promise; +} + /** Optional capability: block until a message arrives (push instead of poll). */ export interface Waitable { /** @@ -90,6 +102,10 @@ export function isClaimable(b: Backend): b is Backend & Claimable { return typeof (b as Partial).claim === 'function'; } +export function isBatchable(b: Backend): b is Backend & Batchable { + return typeof (b as Partial).moveMany === 'function'; +} + export function isWaitable(b: Backend): b is Backend & Waitable { return typeof (b as Partial).waitPush === 'function'; } diff --git a/test/bus.test.ts b/test/bus.test.ts index 65ef00ef..536f0477 100644 --- a/test/bus.test.ts +++ b/test/bus.test.ts @@ -264,3 +264,45 @@ describe('inbox delivers before it consumes (issue #158)', () => { expect((await bus.peek('bob')).map((m) => m.body)).toEqual(['one', 'two']); }); }); + +/** + * Archiving a consumed mailbox is ONE store operation where the backend can + * do it (issue #159) — key-by-key it was a network round trip per message, + * which is what made `inbox` time out on a remote bus. + */ +describe('archive batches when the backend can (issue #159)', () => { + it('uses moveMany once, and falls back to per-key moves when the batch fails', async () => { + const inner = new LocalBackend(await mkTmp()); + let batches = 0; + let singles = 0; + let breakBatch = false; + const backend: Backend & { moveMany(m: { src: string; dst: string }[]): Promise } = { + put: (k, d) => inner.put(k, d), + get: (k) => inner.get(k), + list: (p) => inner.list(p), + delete: (k) => inner.delete(k), + exists: (k) => inner.exists(k), + move: (s, d) => { + singles++; + return inner.move(s, d); + }, + moveMany: async (moves) => { + batches++; + if (breakBatch) throw new Error('batch rejected'); + for (const { src, dst } of moves) await inner.move(src, dst); + }, + }; + const bus = new Bus(backend); + for (const body of ['a', 'b', 'c']) await bus.send({ from: 'x', to: 'bob', body }); + + expect(await bus.inbox('bob')).toHaveLength(3); + expect(batches).toBe(1); + expect(singles).toBe(0); + + breakBatch = true; + for (const body of ['d', 'e']) await bus.send({ from: 'x', to: 'bob', body }); + expect(await bus.inbox('bob')).toHaveLength(2); + expect(singles).toBe(2); // batch refused → each message still archived + expect((await inner.list('inbox/bob/')).length).toBe(0); + }); +}); diff --git a/test/daemon.e2e.test.ts b/test/daemon.e2e.test.ts index 0a8cebad..bf09d62a 100644 --- a/test/daemon.e2e.test.ts +++ b/test/daemon.e2e.test.ts @@ -279,6 +279,40 @@ describe('bus daemon: same semantics, immediate answers', () => { expect((JSON.parse(syncRemote.stdout) as unknown[]).length).toBe(2); }); + it('consuming acks from the outbox and never re-delivers before the drain (issue #159)', async () => { + const dir = await mkTmp(); + const FROZEN = { AGENTCOMM_FLUSH_MS: '600000' }; // flusher effectively off + await run(['register', '--as', 'alpha', '--daemon'], dir, FROZEN); + for (const body of ['one', 'two', 'three']) { + await run(['send', 'alpha', body, '--as', 'beta', '--daemon', '--sync'], dir, FROZEN); + } + + const first = await run(['inbox', '--as', 'alpha', '--daemon', '--json'], dir, FROZEN); + expect((JSON.parse(first.stdout) as { body: string }[]).map((m) => m.body)).toEqual(['one', 'two', 'three']); + + // The archive is still spooled — the store has not seen it yet... + const onStore = await run(['peek', '--as', 'alpha', '--direct', '--json'], dir); + expect((JSON.parse(onStore.stdout) as unknown[]).length).toBe(3); + + // ...but the daemon's own view must NOT resurrect them across a poll, + // however many times it re-lists the store. + await new Promise((r) => setTimeout(r, 900)); // > poll interval + const second = await run(['inbox', '--as', 'alpha', '--daemon', '--json'], dir, FROZEN); + expect(JSON.parse(second.stdout)).toEqual([]); + + // stop drains: the archive lands on the store exactly once + await run(['daemon', 'stop'], dir); + let pending = -1; + for (let i = 0; i < 20 && pending !== 0; i++) { + await new Promise((r) => setTimeout(r, 250)); + const after = await run(['peek', '--as', 'alpha', '--direct', '--json'], dir); + pending = (JSON.parse(after.stdout) as unknown[]).length; + } + expect(pending).toBe(0); + const archived = await fs.readdir(path.join(dir, '.bus', 'read', 'alpha')); + expect(archived).toHaveLength(3); + }); + it('outbox survives a daemon crash: a fresh daemon delivers the leftovers', async () => { const dir = await mkTmp(); const FROZEN = { AGENTCOMM_FLUSH_MS: '600000' }; diff --git a/test/git.e2e.test.ts b/test/git.e2e.test.ts index 7e0ebd94..cd724337 100644 --- a/test/git.e2e.test.ts +++ b/test/git.e2e.test.ts @@ -100,6 +100,28 @@ describe('GitBackend (local bare remotes — same code path as any host)', () => await expect(b.move('inbox/a/1.json', 'read/a/1.json')).rejects.toThrow(/key not found/); }, 60000); + it('consuming a mailbox is ONE commit, not one per message (issue #159)', async () => { + const { uri, cache } = await bareRemote(); + const bus = new Bus(await open(uri, cache)); + for (const body of ['one', 'two', 'three', 'four']) await bus.send({ from: 'p', to: 'reader', body }); + + const remote = uri.replace('git+file://', ''); + const commits = (): number => + Number(execFileSync('git', ['-C', remote, 'rev-list', '--count', 'agentcomm']).toString().trim()); + const before = commits(); + + const got = await bus.inbox('reader'); + expect(got.map((m) => m.body)).toEqual(['one', 'two', 'three', 'four']); + // four messages archived by a single push — key-by-key this was four + // fetch → commit → push round trips, and `inbox` timed out before the + // last one landed. + expect(commits() - before).toBe(1); + + const backend = await open(uri, cache); + expect((await backend.list('read/reader/')).length).toBe(4); + expect((await backend.list('inbox/reader/')).length).toBe(0); + }, 60000); + it('Bus semantics + claim: FIFO dequeue, archives under read/, null on empty', async () => { const { uri, cache } = await bareRemote(); const bus = new Bus(await open(uri, cache));