Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to agent-bridge are documented in this file.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.6.4] - 2026-07-27

### Fixed

- Preserve the original SQLite failure when SQLite has already ended a failed
transaction. Local and edge stores no longer replace it with a second
"cannot rollback" error.

## [0.6.3] - 2026-07-26

### Fixed
Expand Down Expand Up @@ -453,7 +461,8 @@ First tagged release. Marks the point where agent-bridge has shipped its initial
- Narrative-leak detection in CI + on commit via `creatornader/textleaks@v0.2.0` (renamed from leakguard).

[0.1.0]: https://github.com/creatornader/agent-bridge/releases/tag/v0.1.0
[Unreleased]: https://github.com/creatornader/agent-bridge/compare/v0.6.3...HEAD
[Unreleased]: https://github.com/creatornader/agent-bridge/compare/v0.6.4...HEAD
[0.6.4]: https://github.com/creatornader/agent-bridge/compare/v0.6.3...v0.6.4
[0.6.3]: https://github.com/creatornader/agent-bridge/compare/v0.6.2...v0.6.3
[0.6.2]: https://github.com/creatornader/agent-bridge/compare/v0.6.1...v0.6.2
[0.6.1]: https://github.com/creatornader/agent-bridge/compare/v0.6.0...v0.6.1
Expand Down
5 changes: 5 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ The published npm version is the authority for whether this package line has shi
expired crashed-writer lease safely, and keep gateway calls outside the local
write boundary.

### 0.6.4 package contents

- Preserve primary SQLite errors when the engine already ended a failed
transaction, rather than raising a secondary rollback error.

### Production validation and adoption

Completed through the published and deployed 0.6.1 package:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@creatornader/agent-bridge",
"version": "0.6.3",
"version": "0.6.4",
"description": "Let AI agents message each other and hand off work across tools, sessions, and machines.",
"type": "module",
"license": "Apache-2.0",
Expand Down
24 changes: 12 additions & 12 deletions src/sqlite-bridge-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { DatabaseSync as Database, SQLInputValue } from "node:sqlite";
import { DeliveryStateConflictError, cursorScope, decodeCursor, decodeScopedCursor, encodeCursor, encodeScopedCursor, scopedCursorScope, validateDeliveryCursorPosition, validateEventCursorPosition, type AgentPresence, type BridgeDelivery, type BridgeDeliveryEvent, type BridgeMessage, type BridgePrincipal } from "./bridge-domain.js";
import type { BridgeDiagnostics, BridgeStore, ClaimOptions, DeliveryQuery, InsertMessageResult, MessagePage, MessageQuery } from "./bridge-store.js";
import { assertIdempotentReplay } from "./idempotency.js";
import { retrySqliteBusy, SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS } from "./sqlite-retry.js";
import { retrySqliteBusy, rollbackSqliteTransaction, SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS } from "./sqlite-retry.js";
import { preparePrivateSqliteLocation, securePrivatePath, securePrivateSqliteSidecar, verifyPrivatePathAccess } from "./private-path.js";
import { assertLocalUpgradeCandidate, installLocalAuthorityMarkers } from "./sqlite-database-contract.js";

Expand Down Expand Up @@ -127,7 +127,7 @@ export class SQLiteBridgeStore implements BridgeStore {
.run(token, new Date(now.getTime() + 30_000).toISOString(), now.toISOString());
this.db.exec("COMMIT");
return result.changes === 1;
} catch (error) { this.db.exec("ROLLBACK"); throw error; }
} catch (error) { rollbackSqliteTransaction(this.db); throw error; }
}, Math.max(1, deadline - Date.now()));
if (claimed) break;
if (Date.now() >= deadline) throw new Error("local write coordinator remained busy");
Expand All @@ -147,7 +147,7 @@ export class SQLiteBridgeStore implements BridgeStore {
try {
this.db.prepare("UPDATE bridge_write_gates SET lease_token=NULL,lease_expires_at=NULL WHERE gate_key='local' AND lease_token=?").run(token);
this.db.exec("COMMIT");
} catch (error) { this.db.exec("ROLLBACK"); throw error; }
} catch (error) { rollbackSqliteTransaction(this.db); throw error; }
}, waitMs);
} catch (error) {
if (!completed) throw error;
Expand All @@ -164,7 +164,7 @@ export class SQLiteBridgeStore implements BridgeStore {
if (exists()) return;
this.db.exec("BEGIN IMMEDIATE");
try { if (!exists()) this.db.exec(writeGateSchema); this.db.exec("COMMIT"); }
catch (error) { this.db.exec("ROLLBACK"); throw error; }
catch (error) { rollbackSqliteTransaction(this.db); throw error; }
}, timeoutMs);
}
private restrictFiles(includeSidecars = true): void {
Expand Down Expand Up @@ -359,7 +359,7 @@ export class SQLiteBridgeStore implements BridgeStore {
installLocalAuthorityMarkers(this.db);
this.db.exec("COMMIT");
} catch (error) {
this.db.exec("ROLLBACK");
rollbackSqliteTransaction(this.db);
throw error;
}
this.restrictFiles();
Expand All @@ -381,7 +381,7 @@ export class SQLiteBridgeStore implements BridgeStore {
}
}
const row = this.db.prepare("SELECT * FROM bridge_messages WHERE id = ?").get(input.id) as Row; this.db.exec("COMMIT"); return { message: message(row), created: true };
} catch (error) { this.db.exec("ROLLBACK"); throw error; } });
} catch (error) { rollbackSqliteTransaction(this.db); throw error; } });
}
async listMessages(principal: BridgePrincipal, query: MessageQuery = {}): Promise<MessagePage> {
await this.ready(); const scope = cursorScope(principal, query); const cursor = decodeCursor(query.cursor, scope); const limit = Math.min(Math.max(Math.trunc(query.limit ?? 50), 1), 200); const now = new Date().toISOString();
Expand All @@ -408,7 +408,7 @@ export class SQLiteBridgeStore implements BridgeStore {
if (messages.length === limit) return { messages, cursor: encodeCursor(last!.sequence, scope) };
return { messages, cursor: highWater ? encodeCursor(highWater, scope) : query.cursor };
}
async recordReceipt(principal: BridgePrincipal, messageIds: string[], readAt = new Date()): Promise<number> { await this.ready(); return this.write(() => { const stmt = this.db.prepare("INSERT OR IGNORE INTO bridge_receipts (workspace,message_id,principal,read_at) SELECT workspace,id,?,? FROM bridge_messages WHERE workspace=? AND id=? AND (targets='[]' OR EXISTS (SELECT 1 FROM json_each(targets) WHERE value=?))"); let changed = 0; this.db.exec("BEGIN IMMEDIATE"); try { for (const id of messageIds) changed += Number(stmt.run(principal.agent,readAt.toISOString(),principal.workspace,id,principal.agent).changes); this.db.exec("COMMIT"); return changed; } catch (e) { this.db.exec("ROLLBACK"); throw e; } }); }
async recordReceipt(principal: BridgePrincipal, messageIds: string[], readAt = new Date()): Promise<number> { await this.ready(); return this.write(() => { const stmt = this.db.prepare("INSERT OR IGNORE INTO bridge_receipts (workspace,message_id,principal,read_at) SELECT workspace,id,?,? FROM bridge_messages WHERE workspace=? AND id=? AND (targets='[]' OR EXISTS (SELECT 1 FROM json_each(targets) WHERE value=?))"); let changed = 0; this.db.exec("BEGIN IMMEDIATE"); try { for (const id of messageIds) changed += Number(stmt.run(principal.agent,readAt.toISOString(),principal.workspace,id,principal.agent).changes); this.db.exec("COMMIT"); return changed; } catch (e) { rollbackSqliteTransaction(this.db); throw e; } }); }
async claimDelivery(principal: BridgePrincipal, options: ClaimOptions): Promise<BridgeDelivery | null> {
await this.ready(); const now = options.now ?? new Date(); const nowText = now.toISOString(); const expires = new Date(now.getTime() + options.leaseMs).toISOString(); const owner = principal.instance ?? principal.agent;
return this.write(() => { this.db.exec("BEGIN IMMEDIATE"); try {
Expand All @@ -420,7 +420,7 @@ export class SQLiteBridgeStore implements BridgeStore {
const candidateId = String(candidate.id);
const token = randomUUID(); this.db.prepare("UPDATE bridge_deliveries SET state='claimed',attempt=attempt+1,cycle_attempt=cycle_attempt+1,lease_token=?,lease_owner=?,lease_expires_at=?,last_error=NULL,last_actor=?,last_action='claim' WHERE id=?").run(token,owner,expires,principal.agent,candidateId);
const claimed = this.db.prepare("SELECT * FROM bridge_deliveries WHERE id=?").get(candidateId) as Row; this.db.exec("COMMIT"); return delivery(claimed);
} catch (e) { this.db.exec("ROLLBACK"); throw e; } });
} catch (e) { rollbackSqliteTransaction(this.db); throw e; } });
}
async renewDelivery(principal: BridgePrincipal, id: string, token: string, leaseMs: number): Promise<BridgeDelivery | null> { await this.ready(); return this.write(() => { const now = new Date(); const expires = new Date(now.getTime() + leaseMs).toISOString(); const owner = principal.instance ?? principal.agent; const result = this.db.prepare("UPDATE bridge_deliveries SET lease_expires_at=? WHERE workspace=? AND recipient=? AND lease_owner=? AND id=? AND lease_token=? AND state='claimed' AND lease_expires_at > ?").run(expires,principal.workspace,principal.agent,owner,id,token,now.toISOString()); if (!result.changes) return null; return delivery(this.db.prepare("SELECT * FROM bridge_deliveries WHERE id=?").get(id) as Row); }); }
async settleDelivery(principal: BridgePrincipal, id: string, token: string, state: "acked" | "retrying" | "dead", error?: string, _retryPolicy?: import("./bridge-domain.js").RetryPolicy): Promise<BridgeDelivery | null> {
Expand All @@ -439,7 +439,7 @@ export class SQLiteBridgeStore implements BridgeStore {
const action = state === "acked" ? "ack" : state === "dead" ? "nack_dead" : exhausted ? "attempts_exhausted" : "nack_retry";
this.db.prepare("UPDATE bridge_deliveries SET state=?,available_at=?,last_error=?,lease_token=NULL,lease_owner=NULL,lease_expires_at=NULL,last_actor=?,last_action=? WHERE id=?").run(nextState,available,error?.slice(0,1024) ?? null,principal.agent,action,id);
const result = delivery(this.db.prepare("SELECT * FROM bridge_deliveries WHERE id=?").get(id) as Row); this.db.exec("COMMIT"); return result;
} catch (caught) { this.db.exec("ROLLBACK"); throw caught; } });
} catch (caught) { rollbackSqliteTransaction(this.db); throw caught; } });
}
async diagnostics(principal: BridgePrincipal): Promise<BridgeDiagnostics> {
await this.ready();
Expand Down Expand Up @@ -488,7 +488,7 @@ export class SQLiteBridgeStore implements BridgeStore {
this.db.exec("COMMIT");
return result;
} catch (error) {
this.db.exec("ROLLBACK");
rollbackSqliteTransaction(this.db);
throw error;
} });
}
Expand Down Expand Up @@ -537,7 +537,7 @@ export class SQLiteBridgeStore implements BridgeStore {
if (!["pending","retrying","claimed"].includes(String(current.state))) throw new DeliveryStateConflictError(`cannot cancel a ${current.state} delivery`);
this.db.prepare("UPDATE bridge_deliveries SET state='cancelled',lease_token=NULL,lease_owner=NULL,lease_expires_at=NULL,last_error=NULL,last_actor=?,last_action='cancel' WHERE id=?").run(principal.agent,id);
const result = delivery(this.db.prepare("SELECT * FROM bridge_deliveries WHERE id=?").get(id) as Row); this.db.exec("COMMIT"); return result;
} catch (error) { this.db.exec("ROLLBACK"); throw error; } });
} catch (error) { rollbackSqliteTransaction(this.db); throw error; } });
}
async requeueDelivery(principal: BridgePrincipal,id:string) {
await this.ready(); return this.write(() => { this.db.exec("BEGIN IMMEDIATE");
Expand All @@ -549,7 +549,7 @@ export class SQLiteBridgeStore implements BridgeStore {
const policy = parse(current.delivery_policy); const availableAt = policy.notBefore && policy.notBefore > now ? policy.notBefore : now;
this.db.prepare("UPDATE bridge_deliveries SET state='pending',available_at=?,cycle_attempt=0,requeue_count=requeue_count+1,lease_token=NULL,lease_owner=NULL,lease_expires_at=NULL,last_error=NULL,last_actor=?,last_action='requeue' WHERE id=?").run(availableAt,principal.agent,id);
const result = delivery(this.db.prepare("SELECT * FROM bridge_deliveries WHERE id=?").get(id) as Row); this.db.exec("COMMIT"); return result;
} catch (error) { this.db.exec("ROLLBACK"); throw error; } });
} catch (error) { rollbackSqliteTransaction(this.db); throw error; } });
}
async close(): Promise<void> { this.db.close(); }
}
34 changes: 17 additions & 17 deletions src/sqlite-edge-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
type JsonValue,
} from "./bridge-domain.js";
import type { MessagePage, MessageQuery } from "./bridge-store.js";
import { retrySqliteBusy, SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS } from "./sqlite-retry.js";
import { retrySqliteBusy, rollbackSqliteTransaction, SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS } from "./sqlite-retry.js";
import { preparePrivateSqliteLocation, securePrivatePath, securePrivateSqliteSidecar, verifyPrivatePathAccess } from "./private-path.js";
import { assertEdgeUpgradeCandidate, installEdgeMarkers } from "./sqlite-database-contract.js";

Expand Down Expand Up @@ -388,7 +388,7 @@ export class SQLiteEdgeStore {
);
this.db.exec("COMMIT");
return update.changes === 1;
} catch (error) { this.db.exec("ROLLBACK"); throw error; }
} catch (error) { rollbackSqliteTransaction(this.db); throw error; }
}, Math.max(1, deadline - Date.now()));
if (claimed) break;
if (Date.now() >= deadline) throw new EdgeConflictError("edge write coordinator remained busy");
Expand All @@ -408,7 +408,7 @@ export class SQLiteEdgeStore {
try {
this.db.prepare("UPDATE edge_write_gates SET lease_token=NULL,lease_expires_at=NULL WHERE gate_key='edge' AND lease_token=?").run(token);
this.db.exec("COMMIT");
} catch (error) { this.db.exec("ROLLBACK"); throw error; }
} catch (error) { rollbackSqliteTransaction(this.db); throw error; }
}, Math.max(1, Math.trunc(this.writeGateWaitMs)));
} catch (error) {
if (!completed) throw error;
Expand All @@ -425,7 +425,7 @@ export class SQLiteEdgeStore {
if (exists()) return;
this.db.exec("BEGIN IMMEDIATE");
try { if (!exists()) this.db.exec(writeGateSchema); this.db.exec("COMMIT"); }
catch (error) { this.db.exec("ROLLBACK"); throw error; }
catch (error) { rollbackSqliteTransaction(this.db); throw error; }
}, timeoutMs);
}

Expand Down Expand Up @@ -544,7 +544,7 @@ export class SQLiteEdgeStore {
installEdgeMarkers(this.db);
this.db.exec("COMMIT");
} catch (error) {
this.db.exec("ROLLBACK");
rollbackSqliteTransaction(this.db);
throw error;
}
this.restrictFiles();
Expand All @@ -563,7 +563,7 @@ export class SQLiteEdgeStore {
try {
this.db.prepare("UPDATE edge_scopes SET pull_cursor=NULL, cache_contract=1 WHERE scope_key=?").run(this.key);
this.db.exec("COMMIT");
} catch (error) { this.db.exec("ROLLBACK"); throw error; }
} catch (error) { rollbackSqliteTransaction(this.db); throw error; }
}
});
}
Expand All @@ -586,7 +586,7 @@ export class SQLiteEdgeStore {
this.assertScopeActiveInTransaction();
this.db.exec("COMMIT");
} catch (error) {
this.db.exec("ROLLBACK");
rollbackSqliteTransaction(this.db);
throw error;
}
});
Expand All @@ -602,7 +602,7 @@ export class SQLiteEdgeStore {
this.assertDrainLeaseInTransaction(lease, now);
this.db.exec("COMMIT");
} catch (error) {
this.db.exec("ROLLBACK");
rollbackSqliteTransaction(this.db);
throw error;
}
});
Expand Down Expand Up @@ -637,7 +637,7 @@ export class SQLiteEdgeStore {
this.db.exec("COMMIT");
return { scopeKey: this.key, operationId: normalizedOperationId, leaseToken, leaseExpiresAt };
} catch (error) {
this.db.exec("ROLLBACK");
rollbackSqliteTransaction(this.db);
throw error;
}
});
Expand Down Expand Up @@ -684,7 +684,7 @@ export class SQLiteEdgeStore {
}
this.db.exec("COMMIT");
} catch (error) {
this.db.exec("ROLLBACK");
rollbackSqliteTransaction(this.db);
throw error;
}
});
Expand All @@ -708,7 +708,7 @@ export class SQLiteEdgeStore {
this.db.exec("COMMIT");
return { ...lease, leaseExpiresAt };
} catch (error) {
this.db.exec("ROLLBACK");
rollbackSqliteTransaction(this.db);
throw error;
}
});
Expand All @@ -732,7 +732,7 @@ export class SQLiteEdgeStore {
if (updated.changes !== 1) throw new EdgeMigrationLeaseError();
this.db.exec("COMMIT");
} catch (error) {
this.db.exec("ROLLBACK");
rollbackSqliteTransaction(this.db);
throw error;
}
});
Expand Down Expand Up @@ -765,7 +765,7 @@ export class SQLiteEdgeStore {
this.db.exec("COMMIT");
return { draft, created: true };
} catch (error) {
this.db.exec("ROLLBACK");
rollbackSqliteTransaction(this.db);
throw error;
}
});
Expand Down Expand Up @@ -806,7 +806,7 @@ export class SQLiteEdgeStore {
createdAt: String(head.created_at),
};
} catch (error) {
this.db.exec("ROLLBACK");
rollbackSqliteTransaction(this.db);
throw error;
}
});
Expand Down Expand Up @@ -850,7 +850,7 @@ export class SQLiteEdgeStore {
.run(now.toISOString(), this.key);
this.db.exec("COMMIT");
} catch (error) {
this.db.exec("ROLLBACK");
rollbackSqliteTransaction(this.db);
throw error;
}
});
Expand Down Expand Up @@ -903,7 +903,7 @@ export class SQLiteEdgeStore {
.run(now.toISOString(), this.key);
this.db.exec("COMMIT");
} catch (error) {
this.db.exec("ROLLBACK");
rollbackSqliteTransaction(this.db);
throw error;
}
});
Expand All @@ -921,7 +921,7 @@ export class SQLiteEdgeStore {
.run(now.toISOString(), this.key);
this.db.exec("COMMIT");
} catch (error) {
this.db.exec("ROLLBACK");
rollbackSqliteTransaction(this.db);
throw error;
}
});
Expand Down
6 changes: 6 additions & 0 deletions src/sqlite-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@ export async function retrySqliteBusy<T>(
}
}
}

/** SQLite may already have rolled a transaction back after a statement error. */
export function rollbackSqliteTransaction(database: DatabaseSync): void {
if (database.isTransaction) database.exec("ROLLBACK");
}
import type { DatabaseSync } from "node:sqlite";
10 changes: 10 additions & 0 deletions test/sqlite-write-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { SQLiteEdgeStore } from "../src/sqlite-edge-store.js";
import { rollbackSqliteTransaction } from "../src/sqlite-retry.js";
import { privateTestDirectory } from "./private-test-path.js";

const execFileAsync = promisify(execFile);
Expand All @@ -19,6 +20,15 @@ function edgePath(): string {
}

describe("SQLite edge write coordinator", () => {
it("does not mask an error after SQLite already ended a transaction", () => {
const { DatabaseSync } = require("node:sqlite") as typeof import("node:sqlite");
const database = new DatabaseSync(":memory:");
database.exec("BEGIN IMMEDIATE");
database.exec("ROLLBACK");
expect(() => rollbackSqliteTransaction(database)).not.toThrow();
database.close();
});

it("serializes simultaneous post, get, and ack cache mutations", async () => {
const path = edgePath();
const worker = `import { SQLiteEdgeStore } from ${JSON.stringify(new URL("../dist/sqlite.js", import.meta.url).pathname)};
Expand Down