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: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ 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.5] - 2026-07-27

### Fixed

- Inspect managed clients against the recorded launcher contract. A client updated
to an absolute launcher no longer reports false drift when inspected without a
launcher override.
- Read-poll a held SQLite write lease instead of repeatedly opening competing
write transactions. Local and edge stores retry a transient busy failure only
inside the serialized write boundary.

## [0.6.4] - 2026-07-27

### Fixed
Expand Down
8 changes: 8 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,14 @@ The published npm version is the authority for whether this package line has shi
- Preserve primary SQLite errors when the engine already ended a failed
transaction, rather than raising a secondary rollback error.

### 0.6.5 package contents

- Inspect managed host registrations against their stored launcher contract, so a
supported custom absolute launcher remains managed after an update.
- Keep local and edge SQLite write-gate waiters read-only while another process
owns the lease. This prevents a gate probe from colliding with the holder's
actual transaction.

### 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.4",
"version": "0.6.5",
"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: 19 additions & 5 deletions src/client-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,15 @@ export function expectedManagedClientMetadata(
};
}

function exactObject(actual: unknown, expected: unknown): boolean {
return isDeepStrictEqual(actual, expected);
function exactManagedTarget(actual: ManagedClientMetadata | null | undefined, expected: ManagedClientMetadata): boolean {
return actual != null
&& actual.schema === expected.schema
&& actual.version === expected.version
&& actual.runtime === expected.runtime
&& actual.identity === expected.identity
&& actual.instance === expected.instance
&& actual.backendConfigPath === expected.backendConfigPath
&& isDeepStrictEqual(actual.locator, expected.locator);
}

const REGISTRATION_ENV_KEYS = [
Expand Down Expand Up @@ -657,7 +664,11 @@ export function loadManagedClientMetadata(
} finally { closeSync(descriptor); }
}

function readMetadata(path: string, runtime: InstallableRuntime, instance: string): unknown {
function readMetadata(
path: string,
runtime: InstallableRuntime,
instance: string,
): ManagedClientMetadata | null | undefined {
if (!existsSync(path)) return undefined;
try { return loadManagedClientMetadata(runtime, instance, { HOME: dirname(dirname(dirname(path))) }); }
catch { return null; }
Expand Down Expand Up @@ -741,16 +752,19 @@ export function inspectClient(
const normalizedIdentity = identity.trim();
const expected = expectedManagedClientMetadata(runtime, normalizedIdentity, options, env);
const path = managedClientMetadataPath(runtime, expected.instance, env);
const registration = inspectManagedRegistration(expected, execute, env);
const metadata = readMetadata(path, runtime, expected.instance);
// The managed record owns the launch contract after adoption or update. `inspect`
// has no launcher flag, so rebuilding the default here would misclassify a valid
// custom launcher as drifted.
const registration = inspectManagedRegistration(metadata ?? expected, execute, env);
const backend = backendState(expected.backendConfigPath);
let state: ClientLifecycleState;
let reason: string;
if (registration === "absent" && metadata === undefined && backend === "absent") {
state = "absent"; reason = "registration is absent";
} else if (registration === "exact" && backend === "private-file" && metadata === undefined) {
state = "unmanaged"; reason = "exact registration has no managed metadata";
} else if (registration === "exact" && backend === "private-file" && exactObject(metadata, expected)) {
} else if (registration === "exact" && backend === "private-file" && exactManagedTarget(metadata, expected)) {
state = "managed"; reason = "registration and managed metadata are exact";
} else {
state = "drifted"; reason = backend !== "private-file"
Expand Down
11 changes: 9 additions & 2 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, rollbackSqliteTransaction, SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS } from "./sqlite-retry.js";
import { retryAsyncSqliteBusy, 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 @@ -119,6 +119,13 @@ export class SQLiteBridgeStore implements BridgeStore {
let pause = 2;
while (true) {
const now = new Date();
const gate = this.db.prepare("SELECT lease_token,lease_expires_at FROM bridge_write_gates WHERE gate_key='local'").get() as Row | undefined;
if (gate?.lease_token && String(gate.lease_expires_at) > now.toISOString()) {
if (Date.now() >= deadline) throw new Error("local write coordinator remained busy");
await new Promise((resolve) => setTimeout(resolve, pause));
pause = Math.min(Math.ceil(pause * 1.7), 25);
continue;
}
const claimed = await retrySqliteBusy(() => {
this.db.exec("BEGIN IMMEDIATE");
try {
Expand All @@ -137,7 +144,7 @@ export class SQLiteBridgeStore implements BridgeStore {
let completed = false;
let result: T;
try {
result = await work();
result = await retryAsyncSqliteBusy(work, Math.max(1, deadline - Date.now()));
completed = true;
}
finally {
Expand Down
11 changes: 9 additions & 2 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, rollbackSqliteTransaction, SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS } from "./sqlite-retry.js";
import { retryAsyncSqliteBusy, 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 @@ -378,6 +378,13 @@ export class SQLiteEdgeStore {
let pause = 2;
while (true) {
const now = new Date();
const gate = this.db.prepare("SELECT lease_token,lease_expires_at FROM edge_write_gates WHERE gate_key='edge'").get() as Row | undefined;
if (gate?.lease_token && String(gate.lease_expires_at) > now.toISOString()) {
if (Date.now() >= deadline) throw new EdgeConflictError("edge write coordinator remained busy");
await new Promise((resolve) => setTimeout(resolve, pause));
pause = Math.min(Math.ceil(pause * 1.7), 25);
continue;
}
const claimed = await retrySqliteBusy(() => {
this.db.exec("BEGIN IMMEDIATE");
try {
Expand All @@ -398,7 +405,7 @@ export class SQLiteEdgeStore {
let completed = false;
let result: T;
try {
result = await work();
result = await retryAsyncSqliteBusy(work, Math.max(1, deadline - Date.now()));
completed = true;
}
finally {
Expand Down
17 changes: 17 additions & 0 deletions src/sqlite-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,23 @@ export async function retrySqliteBusy<T>(
}
}

export async function retryAsyncSqliteBusy<T>(
operation: () => T | Promise<T>,
timeoutMs: number,
): Promise<T> {
const deadline = Date.now() + Math.max(1, Math.trunc(timeoutMs));
let delayMs = 5;
while (true) {
try {
return await operation();
} catch (error) {
if (!isBusy(error) || Date.now() >= deadline) throw error;
await new Promise((resolve) => setTimeout(resolve, delayMs));
delayMs = Math.min(delayMs * 2, 50);
}
}
}

/** SQLite may already have rolled a transaction back after a statement error. */
export function rollbackSqliteTransaction(database: DatabaseSync): void {
if (database.isTransaction) database.exec("ROLLBACK");
Expand Down
3 changes: 3 additions & 0 deletions test/client-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,9 @@ describe("client lifecycle", () => {
...options, configPath: otherConfigPath,
}).state).toBe("drifted");
expect(inspectClient("claude-desktop", "desktop-work", options).state).toBe("managed");
expect(inspectClient("claude-desktop", "desktop-work", {
instance: "desktop-existing", backendConfigPath, configPath: adoptedConfigPath, env: { HOME: home },
}).state).toBe("managed");
expect(statSync(adoptedConfigPath).mtimeMs).toBe(adoptedBefore.mtimeMs);
expect(statSync(otherConfigPath).mtimeMs).toBe(otherBefore.mtimeMs);
});
Expand Down