From 2a147782dc45c9c66a818bc4e675b66050d7cd81 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Fri, 14 Aug 2026 15:00:32 +0200 Subject: [PATCH] perf(agent-manager): reduce registry refresh writes --- ...ature-agent-registry-write-optimization.md | 63 ++++++++++ ...ature-agent-registry-write-optimization.md | 55 +++++++++ ...ature-agent-registry-write-optimization.md | 25 ++++ ...ature-agent-registry-write-optimization.md | 41 +++++++ ...ature-agent-registry-write-optimization.md | 43 +++++++ packages/agent-manager/src/AgentManager.ts | 11 +- .../src/__tests__/AgentManager.test.ts | 116 +++++++++++++++++- .../src/__tests__/utils/AgentRegistry.test.ts | 45 +++++++ .../agent-manager/src/database/connection.ts | 6 +- packages/agent-manager/src/index.ts | 2 +- .../agent-manager/src/utils/AgentRegistry.ts | 95 +++++++++++--- 11 files changed, 476 insertions(+), 26 deletions(-) create mode 100644 docs/ai/design/2026-08-14-feature-agent-registry-write-optimization.md create mode 100644 docs/ai/implementation/2026-08-14-feature-agent-registry-write-optimization.md create mode 100644 docs/ai/planning/2026-08-14-feature-agent-registry-write-optimization.md create mode 100644 docs/ai/requirements/2026-08-14-feature-agent-registry-write-optimization.md create mode 100644 docs/ai/testing/2026-08-14-feature-agent-registry-write-optimization.md diff --git a/docs/ai/design/2026-08-14-feature-agent-registry-write-optimization.md b/docs/ai/design/2026-08-14-feature-agent-registry-write-optimization.md new file mode 100644 index 00000000..ad440af9 --- /dev/null +++ b/docs/ai/design/2026-08-14-feature-agent-registry-write-optimization.md @@ -0,0 +1,63 @@ +--- +phase: design +title: Agent Registry Write Optimization Design +description: Conditional batch persistence and controlled passive pruning +--- + +# Design + +## Data Flow + +```mermaid +flowchart LR + Poll["listAgents refresh"] --> Detect["adapter detection"] + Detect --> Read["read registry snapshot"] + Read --> Batch["registerBatch"] + Batch -->|"all merged fields equal"| NoWrite["no write transaction"] + Batch -->|"new or changed"| Tx["single atomic write transaction"] + Read --> Cadence{"prune due?"} + Cadence -->|"no"| Skip["skip liveness scan"] + Cadence -->|"yes"| Scan["process.kill(pid, 0)"] + Scan -->|"no stale rows"| NoPruneWrite["no write transaction"] + Scan -->|"stale rows"| DeleteTx["single delete transaction"] +``` + +## Chosen Pruning Contract + +- Add `AgentRegistry.pruneIfDue()` for passive refreshes. +- The first passive call scans immediately; later passive calls scan no more than once every 30 seconds per registry instance. +- Keep `AgentRegistry.prune()` as an immediate forced scan for compatibility and correctness-sensitive flows such as `agent start`. +- Both methods update the same last-pruned timestamp after a successful scan. +- A scan with no stale entries performs no write transaction. +- The clock and interval are constructor options with backward-compatible defaults, enabling deterministic tests without global timers. + +Thirty seconds reduces a three-second console poll from ten scans to one. Stale names are still cleared immediately by start's forced prune, rename checks the conflicting row's liveness directly, registration checks only a relevant name conflict, and kill targets live adapter results. Passive rows disappear within 30 seconds. + +Alternatives considered: + +- Prune on every refresh but avoid an empty delete transaction: rejected because process liveness scans remain on the hot path. +- Prune only on mutations such as start/rename/kill: rejected because stale rows could remain indefinitely during read-only use. +- Persist the last-prune time in SQLite for a cross-process cadence: rejected because it adds migration and coordination writes to optimize a process-local console polling problem. +- Use a 60-second interval: workable, but 30 seconds gives faster passive cleanup while still removing 90% of scans at a three-second poll rate. + +## Conditional Persistence + +`registerBatch()` will merge incoming entries with current rows and compare every persisted field except `updated_at`. If all merged entries are identical, it returns before opening a transaction. If any may change, one transaction retains the existing atomic batch boundary. Each entry is re-read and re-merged inside that transaction so concurrent registry writers cannot make the preflight snapshot authoritative. An entry is upserted only when the transaction-time merged fields differ. + +`updated_at` changes only on insert, actual persisted-field update, or explicit rename. Unchanged detection refreshes do not imply registry updates. + +## Conflict and PID Semantics + +- Dead rows owning an incoming name are deleted inside the same batch transaction. +- A conflicting row with the same PID but a different agent type is stale by definition: one OS PID cannot simultaneously be two provider processes. Delete it even though `kill(pid, 0)` reports the reused PID alive. +- A live different-PID name conflict remains a database constraint error, preserving current conflict visibility. +- Name overlays and existing-entry lookup use `type + pid`, not PID alone, preserving the documented cross-type PID-reuse guard. +- Same-type PID reuse remains accepted until reliable process-start identity is available. + +## Public and Migration Compatibility + +Existing method calls and return types remain valid. The constructor gains only an optional second options argument, and `pruneIfDue()` is additive. No table or migration changes are needed; comparison uses the existing columns. + +## Overlap and Integration Order + +`feature-console-main-thread-responsiveness` is expected to touch `AgentManager.ts` and its tests to share process snapshots across adapters. It must not absorb this registry change. Merge this branch first, then rebase the snapshot branch and compose its detection changes around the conditional registration and `pruneIfDue()` call. Registry SQL and cadence tests should remain owned here; snapshot enumeration tests remain owned there. diff --git a/docs/ai/implementation/2026-08-14-feature-agent-registry-write-optimization.md b/docs/ai/implementation/2026-08-14-feature-agent-registry-write-optimization.md new file mode 100644 index 00000000..f2e772c1 --- /dev/null +++ b/docs/ai/implementation/2026-08-14-feature-agent-registry-write-optimization.md @@ -0,0 +1,55 @@ +--- +phase: implementation +title: Agent Registry Write Optimization Implementation +description: Implementation record for conditional writes and passive prune cadence +--- + +# Implementation Record + +## Status + +Implementation and full-suite verification are complete; publication remains. + +## Intended Files + +- `packages/agent-manager/src/utils/AgentRegistry.ts` +- `packages/agent-manager/src/AgentManager.ts` +- `packages/agent-manager/src/database/connection.ts` +- `packages/agent-manager/src/index.ts` +- `packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts` +- `packages/agent-manager/src/__tests__/AgentManager.test.ts` + +## Design Commitments + +- Compare all persisted entry fields before writes. +- Retain a single transaction for changed batches. +- Revalidate inside the transaction. +- Keep immediate `prune()` and add 30-second `pruneIfDue()`. +- Do not change schema, migrations, process enumeration, or console polling. + +## Implemented Behavior + +- `AgentRegistry.registerBatch()` performs a read-only preflight and returns without a transaction when merged persisted fields are unchanged. +- Potentially changed batches retain one transaction, re-read each identity inside it, and issue at most one upsert per changed entry. +- `updated_at` uses the injected clock and advances only for a real insert/update or rename. +- `pruneIfDue()` scans immediately on its first call and then at a configurable interval defaulting to 30 seconds; `prune()` remains forced. +- Pruning opens a delete transaction only when stale rows exist. +- Cross-type rows sharing a reused PID are removed in the same registration transaction, and manager name overlays use `type + pid`. +- Optional constructor tracing records expanded SQLite operations for deterministic operation-count tests without changing default behavior. + +## Design Alignment + +The implementation matches the design without schema or migration changes. The only additive public surface is `AgentRegistryOptions` plus `pruneIfDue()`. Existing constructor and method calls remain valid. + +## TDD Evidence + +- Initial focused red: 7 failures, including redundant BEGIN/INSERT/COMMIT plus empty prune BEGIN/COMMIT on an unchanged refresh. +- Timestamp regression red: the changed-field test failed when `updated_at` used wall-clock time instead of the injected clock. +- Restored green: `AgentRegistry.test.ts` and `AgentManager.test.ts` passed 67/67, including atomic rollback coverage. +- Full agent-manager suite passed 509/509 with OS process visibility enabled for the existing print-agent integration. +- Full CLI suite passed 959/959 after the required workspace build. +- Full six-project build and lint completed successfully; lint reported six unrelated pre-existing warnings and zero errors. + +## Integration Note + +Land before `feature-console-main-thread-responsiveness`; that branch should rebase and resolve only the shared manager/test call-site overlap. diff --git a/docs/ai/planning/2026-08-14-feature-agent-registry-write-optimization.md b/docs/ai/planning/2026-08-14-feature-agent-registry-write-optimization.md new file mode 100644 index 00000000..71a48c1f --- /dev/null +++ b/docs/ai/planning/2026-08-14-feature-agent-registry-write-optimization.md @@ -0,0 +1,25 @@ +--- +phase: planning +title: Agent Registry Write Optimization Plan +description: TDD plan for conditional writes and controlled pruning +--- + +# Plan + +## Task Queue + +- [x] Add SQL operation tracing and fake-clock test fixtures. +- [x] Red: prove unchanged refreshes currently write and transact. +- [x] Green: skip unchanged upserts and the surrounding write transaction. +- [x] Red/green: persist changed fields exactly once and retain `updated_at` meaning. +- [x] Red/green: add 30-second passive prune cadence plus immediate forced prune. +- [x] Red/green: cover dead-name cleanup, cross-type PID reuse, and rename conflicts. +- [x] Update implementation/testing records and cross-check design alignment. +- [x] Run focused and full agent-manager/CLI tests, lint, typecheck, builds, and docs lint. +- [ ] Commit, rebase on `origin/main`, push, and open a PR without merging. + +## Risks + +- Preflight comparisons could race with another process. Mitigation: re-read and re-merge entries inside the single write transaction. +- Cadence could delay stale-name cleanup. Mitigation: keep `prune()` forced, preserve targeted conflict liveness checks, and bound passive delay to 30 seconds. +- Process-snapshot work could cause merge conflicts. Mitigation: land registry work first and rebase snapshot work afterward. diff --git a/docs/ai/requirements/2026-08-14-feature-agent-registry-write-optimization.md b/docs/ai/requirements/2026-08-14-feature-agent-registry-write-optimization.md new file mode 100644 index 00000000..ae7a9707 --- /dev/null +++ b/docs/ai/requirements/2026-08-14-feature-agent-registry-write-optimization.md @@ -0,0 +1,41 @@ +--- +phase: requirements +title: Agent Registry Write Optimization Requirements +description: Avoid redundant registry writes and bound passive stale-entry pruning +--- + +# Agent Registry Write Optimization + +## Problem + +`AgentManager.listAgents()` is called every three seconds by the console. Every refresh currently upserts every detected agent, advances `updated_at`, and opens a write transaction even when no persisted field changed. It also scans every registry row for process liveness and opens a prune transaction on every refresh. + +## Goals + +- Perform zero SQLite writes and no write transaction for an unchanged refresh. +- Persist each changed or new entry once, while retaining atomic batch behavior. +- Define `updated_at` as the time a persisted field actually changed. +- Put passive pruning on a deterministic cadence so console polling does not scan every three seconds. +- Keep an immediate forced prune for start and other correctness-sensitive callers. +- Preserve name conflict cleanup, rename behavior, live-process checks, PID reuse safeguards, and the existing SQLite schema/migrations. +- Preserve existing public calls to the `AgentRegistry` constructor, `register`, `registerBatch`, `prune`, `rename`, `lookup`, and `list`. + +## Success Criteria + +- SQL operation-count tests prove an unchanged refresh issues zero write statements. +- A changed field produces one row upsert in one batch transaction and advances `updated_at` once. +- A fake clock proves passive pruning runs immediately, is skipped before 30 seconds, and removes newly dead rows at 30 seconds. +- `prune()` remains an immediate forced operation independent of the passive cadence. +- Dead name conflicts, cross-type PID reuse, and rename conflicts retain their cleanup/error behavior. +- Focused and full agent-manager and CLI tests, lint, typecheck, and builds pass. + +## Scope Boundaries + +- Do not add or consume a shared process snapshot. +- Do not change adapter process enumeration or console refresh scheduling. +- Do not change the database schema or migration history. +- Same-type PID reuse remains the previously accepted limitation because reliable detection requires process-start metadata. + +## Integration Constraint + +The separate `feature-console-main-thread-responsiveness` work may replace repeated adapter process enumeration with a per-refresh process snapshot. This feature must land first because it changes only registry persistence and prune scheduling. The snapshot branch should then rebase on this work and preserve the write-elision/cadence tests while resolving any overlap in `AgentManager.ts` and `AgentManager.test.ts`. diff --git a/docs/ai/testing/2026-08-14-feature-agent-registry-write-optimization.md b/docs/ai/testing/2026-08-14-feature-agent-registry-write-optimization.md new file mode 100644 index 00000000..cdae27a7 --- /dev/null +++ b/docs/ai/testing/2026-08-14-feature-agent-registry-write-optimization.md @@ -0,0 +1,43 @@ +--- +phase: testing +title: Agent Registry Write Optimization Testing +description: Deterministic SQL operation-count and fake-clock coverage +--- + +# Testing Strategy + +## Required Deterministic Cases + +- [x] Unchanged `listAgents()` refresh performs zero INSERT/UPDATE/DELETE and opens no write transaction. +- [x] A changed persisted field produces exactly one upsert and advances `updated_at` once. +- [x] Passive prune scans immediately, skips before 30 seconds, and removes newly dead entries at the boundary. +- [x] Forced `prune()` removes newly dead entries even before the passive boundary. +- [x] Cross-type reuse of the same PID replaces stale identity without inheriting its name. +- [x] Dead name conflicts are cleaned up atomically. +- [x] Live rename conflicts still throw and dead rename conflicts are cleaned up. +- [x] A live name conflict rolls back all earlier writes in the same batch. + +## Validation Commands + +- `npm test --workspace @ai-devkit/agent-manager -- AgentRegistry.test.ts AgentManager.test.ts` +- `npm test --workspace @ai-devkit/agent-manager` +- `npm test --workspace ai-devkit` +- `npm run lint --workspace @ai-devkit/agent-manager` +- `npm run lint --workspace ai-devkit` +- `npm run typecheck --workspace @ai-devkit/agent-manager` +- `npm run build --workspace @ai-devkit/agent-manager` +- `npm run build --workspace ai-devkit` +- `npx ai-devkit@latest lint --feature agent-registry-write-optimization` + +## Evidence + +- Red run: focused suite failed 7 tests, and SQL trace showed an unchanged refresh issuing `BEGIN`, `INSERT`, `COMMIT`, `BEGIN`, `COMMIT`. +- Regression red: changed-field timestamp test failed after temporarily restoring wall-clock writes. +- Green run: focused suite passed 67 tests in 2 files. +- `npm run typecheck --workspace @ai-devkit/agent-manager`: exit 0. +- `npm run lint --workspace @ai-devkit/agent-manager`: exit 0. +- `npm test --workspace @ai-devkit/agent-manager`: 24 files, 509 tests passed (rerun with OS process visibility for the existing print-agent integration). +- `npm test --workspace ai-devkit`: 79 files, 959 tests passed after workspace packages were built. +- `npm run build`: all 6 projects built successfully. +- `npm run lint`: all 6 projects linted successfully; 0 errors and 6 unrelated pre-existing warnings. +- `npx ai-devkit@latest lint --feature agent-registry-write-optimization`: all feature checks passed. diff --git a/packages/agent-manager/src/AgentManager.ts b/packages/agent-manager/src/AgentManager.ts index 3220cf90..67d15415 100644 --- a/packages/agent-manager/src/AgentManager.ts +++ b/packages/agent-manager/src/AgentManager.ts @@ -156,15 +156,18 @@ export class AgentManager { }); } - const preExistingByPid = new Map(this.registry.list().map((e) => [e.pid, e])); + const identityKey = (type: string, pid: number): string => `${type}:${pid}`; + const preExistingByIdentity = new Map( + this.registry.list().map((entry) => [identityKey(entry.type, entry.pid), entry]), + ); const entries = allAgents.map((agent) => - this.toRegistryEntry(agent, preExistingByPid.get(agent.pid)), + this.toRegistryEntry(agent, preExistingByIdentity.get(identityKey(agent.type, agent.pid))), ); if (entries.length > 0) this.registry.registerBatch(entries); - this.registry.prune(); + this.registry.pruneIfDue(); for (const agent of allAgents) { - const entry = preExistingByPid.get(agent.pid); + const entry = preExistingByIdentity.get(identityKey(agent.type, agent.pid)); if (entry) { agent.name = entry.name; } diff --git a/packages/agent-manager/src/__tests__/AgentManager.test.ts b/packages/agent-manager/src/__tests__/AgentManager.test.ts index cf6273ed..d46ad794 100644 --- a/packages/agent-manager/src/__tests__/AgentManager.test.ts +++ b/packages/agent-manager/src/__tests__/AgentManager.test.ts @@ -255,12 +255,21 @@ describe('AgentManager', () => { let regPath: string; let registry: AgentRegistry; let scopedManager: AgentManager; + let nowMs: number; + let databaseOperations: string[]; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-manager-')); regPath = path.join(tmpDir, 'agents.json'); - registry = new AgentRegistry(regPath); + nowMs = Date.parse('2026-08-14T10:00:00.000Z'); + databaseOperations = []; + registry = new AgentRegistry(regPath, { + now: () => new Date(nowMs), + pruneIntervalMs: 30_000, + onDatabaseOperation: (sql) => databaseOperations.push(sql), + }); scopedManager = new AgentManager(registry); + databaseOperations = []; }); afterEach(() => { @@ -406,15 +415,114 @@ describe('AgentManager', () => { .toEqual(['a', 'b']); }); - it('skips registerBatch when no agents detected (still calls prune)', async () => { + it('performs zero database writes on an unchanged refresh', async () => { + const adapter = new MockAdapter('claude', [ + createMockAgent({ + name: 'stable', + pid: process.pid, + projectPath: '/cwd/stable', + sessionId: 'stable-session', + sessionFilePath: '/sessions/stable.jsonl', + }), + ]); + scopedManager.registerAdapter(adapter); + await scopedManager.listAgents(); + databaseOperations = []; + + await scopedManager.listAgents(); + + const writes = databaseOperations.filter((sql) => /^\s*(BEGIN|COMMIT|INSERT|UPDATE|DELETE)/i.test(sql)); + expect(writes).toEqual([]); + }); + + it('persists changed fields once in one write transaction', async () => { + const adapter = new MockAdapter('claude', [ + createMockAgent({ name: 'changing', pid: process.pid, projectPath: '/cwd/before' }), + ]); + scopedManager.registerAdapter(adapter); + await scopedManager.listAgents(); + databaseOperations = []; + nowMs += 1_000; + adapter.setAgents([ + createMockAgent({ name: 'changing', pid: process.pid, projectPath: '/cwd/after' }), + ]); + + await scopedManager.listAgents(); + + const upserts = databaseOperations.filter((sql) => /^\s*INSERT INTO agents/i.test(sql)); + const transactions = databaseOperations.filter((sql) => /^\s*(BEGIN|COMMIT)/i.test(sql)); + expect(upserts).toHaveLength(1); + expect(upserts[0]).toContain("'2026-08-14T10:00:01.000Z'"); + expect(transactions).toHaveLength(2); + expect(registry.lookup('changing')?.cwd).toBe('/cwd/after'); + }); + + it('prunes newly dead entries only when the passive cadence is due', async () => { + registry.register({ + name: 'cadenced', + type: 'claude', + pid: process.pid, + tmuxSession: '', + cwd: '/cwd/cadenced', + startedAt: '2026-05-30T00:00:00.000Z', + sessionId: 'sid-cadenced', + sessionFilePath: '', + }); + const alive = vi.spyOn(registry, 'isAlive').mockReturnValue(true); + + await scopedManager.listAgents(); + expect(alive).toHaveBeenCalledTimes(1); + alive.mockReturnValue(false); + nowMs += 29_999; + + await scopedManager.listAgents(); + expect(alive).toHaveBeenCalledTimes(1); + expect(registry.lookup('cadenced')).not.toBeNull(); + + nowMs += 1; + await scopedManager.listAgents(); + expect(alive).toHaveBeenCalledTimes(2); + expect(registry.lookup('cadenced')).toBeNull(); + }); + + it('does not inherit a name when the same pid is reused by another agent type', async () => { + registry.register({ + name: 'old-claude', + type: 'claude', + pid: process.pid, + tmuxSession: 'old-claude', + cwd: '/cwd/old', + startedAt: '2026-05-30T00:00:00.000Z', + sessionId: 'old-session', + sessionFilePath: '', + }); + scopedManager.registerAdapter(new MockAdapter('codex', [ + createMockAgent({ + name: 'new-codex', + type: 'codex', + pid: process.pid, + projectPath: '/cwd/new', + sessionId: 'new-session', + }), + ])); + + const agents = await scopedManager.listAgents(); + + expect(agents[0].name).toBe('new-codex'); + expect(registry.lookup('old-claude')).toBeNull(); + expect(registry.lookup('new-codex')).toMatchObject({ type: 'codex', pid: process.pid }); + }); + + it('skips registerBatch when no agents are detected and prune is not due', async () => { const writeSpy = vi.spyOn(registry, 'registerBatch'); - const pruneSpy = vi.spyOn(registry, 'prune'); + const pruneSpy = vi.spyOn(registry, 'pruneIfDue'); scopedManager.registerAdapter(new MockAdapter('claude', [])); await scopedManager.listAgents(); + await scopedManager.listAgents(); expect(writeSpy).not.toHaveBeenCalled(); - expect(pruneSpy).toHaveBeenCalledTimes(1); + expect(pruneSpy).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts b/packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts index 867e3951..c33cb784 100644 --- a/packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts +++ b/packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts @@ -118,6 +118,33 @@ describe('AgentRegistry', () => { expect(registry.list()).toHaveLength(1); expect(registry.lookup('custom-name')?.pid).toBe(process.pid); }); + + it('cleans up a cross-type row when its pid has been reused', () => { + registry.register(makeEntry({ name: 'old-claude', type: 'claude', pid: process.pid })); + + registry.register(makeEntry({ + name: 'new-codex', + type: 'codex', + pid: process.pid, + tmuxSession: '', + })); + + expect(registry.lookup('old-claude')).toBeNull(); + expect(registry.lookup('new-codex')).toMatchObject({ type: 'codex', pid: process.pid }); + expect(registry.list()).toHaveLength(1); + }); + + it('rolls back the whole batch when a live name conflict rejects one entry', () => { + registry.register(makeEntry({ name: 'taken', pid: process.pid })); + + expect(() => registry.registerBatch([ + makeEntry({ name: 'fresh', pid: 999998 }), + makeEntry({ name: 'taken', type: 'codex', pid: 999997 }), + ])).toThrow(/UNIQUE constraint failed/); + + expect(registry.lookup('fresh')).toBeNull(); + expect(registry.lookup('taken')?.pid).toBe(process.pid); + }); }); describe('lookup', () => { @@ -180,6 +207,24 @@ describe('AgentRegistry', () => { it('does nothing when file is missing', () => { expect(() => registry.prune()).not.toThrow(); }); + + it('keeps forced prune available before the passive cadence is due', () => { + let nowMs = Date.parse('2026-08-14T10:00:00.000Z'); + const clocked = new AgentRegistry(regPath, { + now: () => new Date(nowMs), + pruneIntervalMs: 30_000, + }); + clocked.register(makeEntry({ name: 'forced', pid: process.pid })); + const alive = vi.spyOn(clocked, 'isAlive').mockReturnValue(true); + clocked.pruneIfDue(); + alive.mockReturnValue(false); + nowMs += 1; + + clocked.prune(); + + expect(alive).toHaveBeenCalledTimes(2); + expect(clocked.lookup('forced')).toBeNull(); + }); }); describe('default()', () => { diff --git a/packages/agent-manager/src/database/connection.ts b/packages/agent-manager/src/database/connection.ts index db760c41..30f0d37a 100644 --- a/packages/agent-manager/src/database/connection.ts +++ b/packages/agent-manager/src/database/connection.ts @@ -8,7 +8,7 @@ export const DEFAULT_AGENT_REGISTRY_DB_PATH = join(homedir(), '.ai-devkit', 'age export interface DatabaseOptions { dbPath?: string; - verbose?: boolean; + verbose?: boolean | ((message: string) => void); readonly?: boolean; } @@ -27,7 +27,9 @@ export class DatabaseConnection { this.db = new Database(this.dbPath, { readonly: options.readonly ?? false, - verbose: options.verbose ? console.log : undefined, + verbose: typeof options.verbose === 'function' + ? options.verbose + : options.verbose ? console.log : undefined, }); this.configure(); diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index ced400ec..78ce77e2 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -27,7 +27,7 @@ export type { AgentSortKey } from './utils/sortAgents.js'; export type { ListAgentsOptions } from './AgentManager.js'; export { AgentRegistry, RenameNotFoundError, RenameConflictError } from './utils/AgentRegistry.js'; -export type { RegistryEntry } from './utils/AgentRegistry.js'; +export type { AgentRegistryOptions, RegistryEntry } from './utils/AgentRegistry.js'; export { TmuxManager } from './terminal/TmuxManager.js'; export { AGENTS } from './utils/agents.js'; export type { AgentConfig, StartableAgentType } from './utils/agents.js'; diff --git a/packages/agent-manager/src/utils/AgentRegistry.ts b/packages/agent-manager/src/utils/AgentRegistry.ts index 83d25216..25e98435 100644 --- a/packages/agent-manager/src/utils/AgentRegistry.ts +++ b/packages/agent-manager/src/utils/AgentRegistry.ts @@ -44,14 +44,29 @@ interface RegistryRow { } const DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'agents.json'); +const DEFAULT_PRUNE_INTERVAL_MS = 30_000; let defaultInstance: AgentRegistry | null = null; +export interface AgentRegistryOptions { + now?: () => Date; + pruneIntervalMs?: number; + onDatabaseOperation?: (sql: string) => void; +} + export class AgentRegistry { private db: DatabaseConnection; + private readonly now: () => Date; + private readonly pruneIntervalMs: number; + private lastPrunedAt: number | undefined; - constructor(filePath: string = DEFAULT_REGISTRY_PATH) { - this.db = new DatabaseConnection({ dbPath: resolveAgentRegistryDbPath(filePath) }); + constructor(filePath: string = DEFAULT_REGISTRY_PATH, options: AgentRegistryOptions = {}) { + this.now = options.now ?? (() => new Date()); + this.pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS; + this.db = new DatabaseConnection({ + dbPath: resolveAgentRegistryDbPath(filePath), + verbose: options.onDatabaseOperation, + }); } static default(): AgentRegistry { @@ -101,6 +116,24 @@ export class AgentRegistry { return row ? this.rowToEntry(row) : undefined; } + private findPidConflicts(type: AgentType, pid: number): RegistryEntry[] { + return this.db.query( + 'SELECT * FROM agents WHERE pid = ? AND type <> ?', + [pid, type], + ).map((row) => this.rowToEntry(row)); + } + + private entriesEqual(left: RegistryEntry, right: RegistryEntry): boolean { + return left.name === right.name + && left.type === right.type + && left.pid === right.pid + && left.tmuxSession === right.tmuxSession + && left.cwd === right.cwd + && left.startedAt === right.startedAt + && left.sessionId === right.sessionId + && left.sessionFilePath === right.sessionFilePath; + } + private deleteNameConflict(name: string, type: AgentType, pid: number): void { const conflict = this.findByName(name); if (!conflict) return; @@ -126,12 +159,30 @@ export class AgentRegistry { session_id = excluded.session_id, session_file_path = excluded.session_file_path, updated_at = excluded.updated_at - `).run({ ...entry, updatedAt: new Date().toISOString() }); + `).run({ ...entry, updatedAt: this.now().toISOString() }); } - private save(entry: RegistryEntry): void { - this.deleteNameConflict(entry.name, entry.type, entry.pid); - this.insertOrUpdate(entry); + private needsWrite(incoming: RegistryEntry): boolean { + const existing = this.findByIdentity(incoming.type, incoming.pid); + const merged = this.mergeEntry(incoming, existing); + return !existing + || !this.entriesEqual(merged, existing) + || this.findPidConflicts(incoming.type, incoming.pid).length > 0; + } + + private save(incoming: RegistryEntry): void { + const existing = this.findByIdentity(incoming.type, incoming.pid); + const merged = this.mergeEntry(incoming, existing); + const pidConflicts = this.findPidConflicts(incoming.type, incoming.pid); + if (existing && this.entriesEqual(merged, existing) && pidConflicts.length === 0) return; + + for (const conflict of pidConflicts) { + this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]); + } + if (existing && this.entriesEqual(merged, existing)) return; + + this.deleteNameConflict(merged.name, merged.type, merged.pid); + this.insertOrUpdate(merged); } isAlive(entry: RegistryEntry): boolean { @@ -143,14 +194,28 @@ export class AgentRegistry { } } - prune(): void { + private pruneAt(nowMs: number): void { const entries = this.list(); const stale = entries.filter((e) => !this.isAlive(e)); - this.db.transaction(() => { - for (const entry of stale) { - this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [entry.type, entry.pid]); - } - }); + if (stale.length > 0) { + this.db.transaction(() => { + for (const entry of stale) { + this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [entry.type, entry.pid]); + } + }); + } + this.lastPrunedAt = nowMs; + } + + prune(): void { + this.pruneAt(this.now().getTime()); + } + + pruneIfDue(): void { + const nowMs = this.now().getTime(); + const elapsed = this.lastPrunedAt === undefined ? undefined : nowMs - this.lastPrunedAt; + if (elapsed !== undefined && elapsed >= 0 && elapsed < this.pruneIntervalMs) return; + this.pruneAt(nowMs); } register(entry: RegistryEntry): void { @@ -159,10 +224,10 @@ export class AgentRegistry { registerBatch(entries: RegistryEntry[]): void { if (entries.length === 0) return; + if (!entries.some((entry) => this.needsWrite(entry))) return; this.db.transaction(() => { for (const incoming of entries) { - const existing = this.findByIdentity(incoming.type, incoming.pid); - this.save(this.mergeEntry(incoming, existing)); + this.save(incoming); } }); } @@ -183,7 +248,7 @@ export class AgentRegistry { } this.db.execute( 'UPDATE agents SET name = ?, updated_at = ? WHERE type = ? AND pid = ?', - [newName, new Date().toISOString(), existing.type, existing.pid], + [newName, this.now().toISOString(), existing.type, existing.pid], ); }); }