From 0738fba5dccaf137fd8463c6558c5d925f341c99 Mon Sep 17 00:00:00 2001 From: Nader Helmy Date: Sun, 26 Jul 2026 23:08:10 -0500 Subject: [PATCH] fix: serialize SQLite write-gate waiters --- CHANGELOG.md | 11 +++++++++++ ROADMAP.md | 8 ++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/client-lifecycle.ts | 24 +++++++++++++++++++----- src/sqlite-bridge-store.ts | 11 +++++++++-- src/sqlite-edge-store.ts | 11 +++++++++-- src/sqlite-retry.ts | 17 +++++++++++++++++ test/client-lifecycle.test.ts | 3 +++ 9 files changed, 79 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e51f4c2..73914e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 7977814..f67c0f6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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: diff --git a/package-lock.json b/package-lock.json index 7748144..2c727ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@creatornader/agent-bridge", - "version": "0.6.4", + "version": "0.6.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@creatornader/agent-bridge", - "version": "0.6.4", + "version": "0.6.5", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", diff --git a/package.json b/package.json index df80422..20128b7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client-lifecycle.ts b/src/client-lifecycle.ts index a19007e..cd8911a 100644 --- a/src/client-lifecycle.ts +++ b/src/client-lifecycle.ts @@ -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 = [ @@ -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; } @@ -741,8 +752,11 @@ 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; @@ -750,7 +764,7 @@ export function inspectClient( 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" diff --git a/src/sqlite-bridge-store.ts b/src/sqlite-bridge-store.ts index e2a9220..1699915 100644 --- a/src/sqlite-bridge-store.ts +++ b/src/sqlite-bridge-store.ts @@ -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"; @@ -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 { @@ -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 { diff --git a/src/sqlite-edge-store.ts b/src/sqlite-edge-store.ts index 10a1056..2d5f188 100644 --- a/src/sqlite-edge-store.ts +++ b/src/sqlite-edge-store.ts @@ -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"; @@ -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 { @@ -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 { diff --git a/src/sqlite-retry.ts b/src/sqlite-retry.ts index a1897e9..22c6faa 100644 --- a/src/sqlite-retry.ts +++ b/src/sqlite-retry.ts @@ -23,6 +23,23 @@ export async function retrySqliteBusy( } } +export async function retryAsyncSqliteBusy( + operation: () => T | Promise, + timeoutMs: number, +): Promise { + 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"); diff --git a/test/client-lifecycle.test.ts b/test/client-lifecycle.test.ts index b11a2a3..5d55d1b 100644 --- a/test/client-lifecycle.test.ts +++ b/test/client-lifecycle.test.ts @@ -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); });