@ai-devkit/agent-manager 0.26.3 → 0.26.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AgentManager.d.ts.map +1 -1
- package/dist/AgentManager.js +7 -6
- package/dist/AgentManager.js.map +1 -1
- package/dist/__tests__/AgentManager.test.js +142 -4
- package/dist/__tests__/AgentManager.test.js.map +1 -1
- package/dist/__tests__/utils/AgentRegistry.test.js +140 -0
- package/dist/__tests__/utils/AgentRegistry.test.js.map +1 -1
- package/dist/database/connection.d.ts +1 -1
- package/dist/database/connection.d.ts.map +1 -1
- package/dist/database/connection.js +1 -1
- package/dist/database/connection.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/utils/AgentRegistry.d.ts +14 -1
- package/dist/utils/AgentRegistry.d.ts.map +1 -1
- package/dist/utils/AgentRegistry.js +65 -20
- package/dist/utils/AgentRegistry.js.map +1 -1
- package/package.json +1 -1
- package/src/AgentManager.ts +7 -4
- package/src/__tests__/AgentManager.test.ts +142 -4
- package/src/__tests__/utils/AgentRegistry.test.ts +119 -0
- package/src/database/connection.ts +4 -2
- package/src/index.ts +1 -1
- package/src/utils/AgentRegistry.ts +85 -17
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/AgentRegistry.ts"],"sourcesContent":["import os from 'os';\nimport path from 'path';\nimport type { AgentType } from '../adapters/AgentAdapter.js';\nimport {\n DatabaseConnection,\n resolveAgentRegistryDbPath,\n} from '../database/index.js';\n\nexport class RenameNotFoundError extends Error {\n constructor(public agentName: string) {\n super(`Agent \"${agentName}\" not found in registry.`);\n this.name = 'RenameNotFoundError';\n }\n}\n\nexport class RenameConflictError extends Error {\n constructor(public agentName: string) {\n super(`Agent \"${agentName}\" is already in use.`);\n this.name = 'RenameConflictError';\n }\n}\n\nexport interface RegistryEntry {\n name: string;\n type: AgentType;\n pid: number;\n tmuxSession: string;\n cwd: string;\n startedAt: string; // ISO 8601\n sessionId: string;\n sessionFilePath: string;\n}\n\ninterface RegistryRow {\n name: string;\n type: AgentType;\n pid: number;\n tmux_session: string;\n cwd: string;\n started_at: string;\n session_id: string;\n session_file_path: string;\n updated_at: string;\n}\n\nconst DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'agents.json');\n\nlet defaultInstance: AgentRegistry | null = null;\n\nexport class AgentRegistry {\n private db: DatabaseConnection;\n\n constructor(filePath: string = DEFAULT_REGISTRY_PATH) {\n this.db = new DatabaseConnection({ dbPath: resolveAgentRegistryDbPath(filePath) });\n }\n\n static default(): AgentRegistry {\n if (!defaultInstance) {\n defaultInstance = new AgentRegistry();\n }\n return defaultInstance;\n }\n\n private rowToEntry(row: RegistryRow): RegistryEntry {\n return {\n name: row.name,\n type: row.type,\n pid: row.pid,\n tmuxSession: row.tmux_session,\n cwd: row.cwd,\n startedAt: row.started_at,\n sessionId: row.session_id,\n sessionFilePath: row.session_file_path,\n };\n }\n\n private mergeEntry(incoming: RegistryEntry, existing: RegistryEntry | undefined): RegistryEntry {\n if (!existing) return incoming;\n const incomingIsManaged = Boolean(incoming.tmuxSession);\n return {\n ...existing,\n name: incomingIsManaged ? incoming.name : existing.name,\n tmuxSession: incoming.tmuxSession || existing.tmuxSession,\n cwd: incoming.cwd || existing.cwd,\n startedAt: existing.startedAt || incoming.startedAt,\n sessionId: incoming.sessionId || existing.sessionId,\n sessionFilePath: incoming.sessionFilePath || existing.sessionFilePath,\n };\n }\n\n private findByIdentity(type: AgentType, pid: number): RegistryEntry | undefined {\n const row = this.db.queryOne<RegistryRow>(\n 'SELECT * FROM agents WHERE type = ? AND pid = ?',\n [type, pid],\n );\n return row ? this.rowToEntry(row) : undefined;\n }\n\n private findByName(name: string): RegistryEntry | undefined {\n const row = this.db.queryOne<RegistryRow>('SELECT * FROM agents WHERE name = ?', [name]);\n return row ? this.rowToEntry(row) : undefined;\n }\n\n private deleteNameConflict(name: string, type: AgentType, pid: number): void {\n const conflict = this.findByName(name);\n if (!conflict) return;\n if (conflict.type === type && conflict.pid === pid) return;\n if (!this.isAlive(conflict)) {\n this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);\n }\n }\n\n private insertOrUpdate(entry: RegistryEntry): void {\n this.db.instance.prepare(`\n INSERT INTO agents (\n type, pid, name, tmux_session, cwd, started_at, session_id, session_file_path, updated_at\n )\n VALUES (\n @type, @pid, @name, @tmuxSession, @cwd, @startedAt, @sessionId, @sessionFilePath, @updatedAt\n )\n ON CONFLICT(type, pid) DO UPDATE SET\n name = excluded.name,\n tmux_session = excluded.tmux_session,\n cwd = excluded.cwd,\n started_at = agents.started_at,\n session_id = excluded.session_id,\n session_file_path = excluded.session_file_path,\n updated_at = excluded.updated_at\n `).run({ ...entry, updatedAt: new Date().toISOString() });\n }\n\n private save(entry: RegistryEntry): void {\n this.deleteNameConflict(entry.name, entry.type, entry.pid);\n this.insertOrUpdate(entry);\n }\n\n isAlive(entry: RegistryEntry): boolean {\n try {\n process.kill(entry.pid, 0);\n return true;\n } catch {\n return false;\n }\n }\n\n prune(): void {\n const entries = this.list();\n const stale = entries.filter((e) => !this.isAlive(e));\n this.db.transaction(() => {\n for (const entry of stale) {\n this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [entry.type, entry.pid]);\n }\n });\n }\n\n register(entry: RegistryEntry): void {\n this.registerBatch([entry]);\n }\n\n registerBatch(entries: RegistryEntry[]): void {\n if (entries.length === 0) return;\n this.db.transaction(() => {\n for (const incoming of entries) {\n const existing = this.findByIdentity(incoming.type, incoming.pid);\n this.save(this.mergeEntry(incoming, existing));\n }\n });\n }\n\n rename(currentName: string, newName: string): void {\n const existing = this.findByName(currentName);\n if (!existing) {\n throw new RenameNotFoundError(currentName);\n }\n const conflict = this.findByName(newName);\n if (conflict && this.isAlive(conflict)) {\n throw new RenameConflictError(newName);\n }\n\n this.db.transaction(() => {\n if (conflict) {\n this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);\n }\n this.db.execute(\n 'UPDATE agents SET name = ?, updated_at = ? WHERE type = ? AND pid = ?',\n [newName, new Date().toISOString(), existing.type, existing.pid],\n );\n });\n }\n\n lookup(name: string): RegistryEntry | null {\n return this.findByName(name) ?? null;\n }\n\n list(): RegistryEntry[] {\n const rows = this.db.query<RegistryRow>('SELECT * FROM agents ORDER BY started_at ASC, name ASC');\n return rows.map((row) => this.rowToEntry(row));\n }\n}\n"],"names":["os","path","DatabaseConnection","resolveAgentRegistryDbPath","RenameNotFoundError","Error","agentName","name","RenameConflictError","DEFAULT_REGISTRY_PATH","join","homedir","defaultInstance","AgentRegistry","db","filePath","dbPath","default","rowToEntry","row","type","pid","tmuxSession","tmux_session","cwd","startedAt","started_at","sessionId","session_id","sessionFilePath","session_file_path","mergeEntry","incoming","existing","incomingIsManaged","Boolean","findByIdentity","queryOne","undefined","findByName","deleteNameConflict","conflict","isAlive","execute","insertOrUpdate","entry","instance","prepare","run","updatedAt","Date","toISOString","save","process","kill","prune","entries","list","stale","filter","e","transaction","register","registerBatch","length","rename","currentName","newName","lookup","rows","query","map"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/AgentRegistry.ts"],"sourcesContent":["import os from 'os';\nimport path from 'path';\nimport type { AgentType } from '../adapters/AgentAdapter.js';\nimport {\n DatabaseConnection,\n resolveAgentRegistryDbPath,\n} from '../database/index.js';\n\nexport class RenameNotFoundError extends Error {\n constructor(public agentName: string) {\n super(`Agent \"${agentName}\" not found in registry.`);\n this.name = 'RenameNotFoundError';\n }\n}\n\nexport class RenameConflictError extends Error {\n constructor(public agentName: string) {\n super(`Agent \"${agentName}\" is already in use.`);\n this.name = 'RenameConflictError';\n }\n}\n\nexport interface RegistryEntry {\n name: string;\n type: AgentType;\n pid: number;\n tmuxSession: string;\n cwd: string;\n startedAt: string; // ISO 8601\n sessionId: string;\n sessionFilePath: string;\n}\n\ninterface RegistryRow {\n name: string;\n type: AgentType;\n pid: number;\n tmux_session: string;\n cwd: string;\n started_at: string;\n session_id: string;\n session_file_path: string;\n updated_at: string;\n}\n\nconst DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'agents.json');\nconst DEFAULT_PRUNE_INTERVAL_MS = 30_000;\n\nlet defaultInstance: AgentRegistry | null = null;\n\nexport interface AgentRegistryOptions {\n now?: () => Date;\n pruneIntervalMs?: number;\n onDatabaseOperation?: (sql: string) => void;\n}\n\nexport class AgentRegistry {\n private db: DatabaseConnection;\n private readonly now: () => Date;\n private readonly pruneIntervalMs: number;\n private lastPrunedAt: number | undefined;\n\n constructor(filePath: string = DEFAULT_REGISTRY_PATH, options: AgentRegistryOptions = {}) {\n this.now = options.now ?? (() => new Date());\n this.pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS;\n this.db = new DatabaseConnection({\n dbPath: resolveAgentRegistryDbPath(filePath),\n verbose: options.onDatabaseOperation,\n });\n }\n\n static default(): AgentRegistry {\n if (!defaultInstance) {\n defaultInstance = new AgentRegistry();\n }\n return defaultInstance;\n }\n\n private rowToEntry(row: RegistryRow): RegistryEntry {\n return {\n name: row.name,\n type: row.type,\n pid: row.pid,\n tmuxSession: row.tmux_session,\n cwd: row.cwd,\n startedAt: row.started_at,\n sessionId: row.session_id,\n sessionFilePath: row.session_file_path,\n };\n }\n\n private mergeEntry(incoming: RegistryEntry, existing: RegistryEntry | undefined): RegistryEntry {\n if (!existing) return incoming;\n const incomingIsManaged = Boolean(incoming.tmuxSession);\n return {\n ...existing,\n name: incomingIsManaged ? incoming.name : existing.name,\n tmuxSession: incoming.tmuxSession || existing.tmuxSession,\n cwd: incoming.cwd || existing.cwd,\n startedAt: existing.startedAt || incoming.startedAt,\n sessionId: incoming.sessionId || existing.sessionId,\n sessionFilePath: incoming.sessionFilePath || existing.sessionFilePath,\n };\n }\n\n private findByIdentity(type: AgentType, pid: number): RegistryEntry | undefined {\n const row = this.db.queryOne<RegistryRow>(\n 'SELECT * FROM agents WHERE type = ? AND pid = ?',\n [type, pid],\n );\n return row ? this.rowToEntry(row) : undefined;\n }\n\n private findByName(name: string): RegistryEntry | undefined {\n const row = this.db.queryOne<RegistryRow>('SELECT * FROM agents WHERE name = ?', [name]);\n return row ? this.rowToEntry(row) : undefined;\n }\n\n private findPidConflicts(type: AgentType, pid: number): RegistryEntry[] {\n return this.db.query<RegistryRow>(\n 'SELECT * FROM agents WHERE pid = ? AND type <> ?',\n [pid, type],\n ).map((row) => this.rowToEntry(row));\n }\n\n private entriesEqual(left: RegistryEntry, right: RegistryEntry): boolean {\n return left.name === right.name\n && left.type === right.type\n && left.pid === right.pid\n && left.tmuxSession === right.tmuxSession\n && left.cwd === right.cwd\n && left.startedAt === right.startedAt\n && left.sessionId === right.sessionId\n && left.sessionFilePath === right.sessionFilePath;\n }\n\n private deleteNameConflict(name: string, type: AgentType, pid: number): void {\n const conflict = this.findByName(name);\n if (!conflict) return;\n if (conflict.type === type && conflict.pid === pid) return;\n if (!this.isAlive(conflict)) {\n this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);\n }\n }\n\n private insertOrUpdate(entry: RegistryEntry): void {\n this.db.instance.prepare(`\n INSERT INTO agents (\n type, pid, name, tmux_session, cwd, started_at, session_id, session_file_path, updated_at\n )\n VALUES (\n @type, @pid, @name, @tmuxSession, @cwd, @startedAt, @sessionId, @sessionFilePath, @updatedAt\n )\n ON CONFLICT(type, pid) DO UPDATE SET\n name = excluded.name,\n tmux_session = excluded.tmux_session,\n cwd = excluded.cwd,\n started_at = agents.started_at,\n session_id = excluded.session_id,\n session_file_path = excluded.session_file_path,\n updated_at = excluded.updated_at\n `).run({ ...entry, updatedAt: this.now().toISOString() });\n }\n\n private needsWrite(incoming: RegistryEntry): boolean {\n const existing = this.findByIdentity(incoming.type, incoming.pid);\n const merged = this.mergeEntry(incoming, existing);\n return !existing\n || !this.entriesEqual(merged, existing)\n || this.findPidConflicts(incoming.type, incoming.pid).length > 0;\n }\n\n private save(incoming: RegistryEntry): void {\n const existing = this.findByIdentity(incoming.type, incoming.pid);\n const merged = this.mergeEntry(incoming, existing);\n const pidConflicts = this.findPidConflicts(incoming.type, incoming.pid);\n if (existing && this.entriesEqual(merged, existing) && pidConflicts.length === 0) return;\n\n for (const conflict of pidConflicts) {\n this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);\n }\n if (existing && this.entriesEqual(merged, existing)) return;\n\n this.deleteNameConflict(merged.name, merged.type, merged.pid);\n this.insertOrUpdate(merged);\n }\n\n isAlive(entry: RegistryEntry): boolean {\n try {\n process.kill(entry.pid, 0);\n return true;\n } catch (error) {\n const code = error && typeof error === 'object' && 'code' in error\n ? error.code\n : undefined;\n return code !== 'ESRCH';\n }\n }\n\n private pruneAt(nowMs: number): void {\n const entries = this.list();\n const stale = entries.filter((e) => !this.isAlive(e));\n if (stale.length > 0) {\n this.db.transaction(() => {\n for (const entry of stale) {\n this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [entry.type, entry.pid]);\n }\n });\n }\n this.lastPrunedAt = nowMs;\n }\n\n prune(): void {\n this.pruneAt(this.now().getTime());\n }\n\n pruneIfDue(): void {\n const nowMs = this.now().getTime();\n const elapsed = this.lastPrunedAt === undefined ? undefined : nowMs - this.lastPrunedAt;\n if (elapsed !== undefined && elapsed >= 0 && elapsed < this.pruneIntervalMs) return;\n this.pruneAt(nowMs);\n }\n\n register(entry: RegistryEntry): void {\n this.registerBatch([entry]);\n }\n\n registerBatch(entries: RegistryEntry[]): void {\n if (entries.length === 0) return;\n if (!entries.some((entry) => this.needsWrite(entry))) return;\n this.db.transaction(() => {\n for (const incoming of entries) {\n this.save(incoming);\n }\n });\n }\n\n rename(currentName: string, newName: string): void {\n const existing = this.findByName(currentName);\n if (!existing) {\n throw new RenameNotFoundError(currentName);\n }\n const conflict = this.findByName(newName);\n if (conflict && this.isAlive(conflict)) {\n throw new RenameConflictError(newName);\n }\n\n this.db.transaction(() => {\n if (conflict) {\n this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);\n }\n this.db.execute(\n 'UPDATE agents SET name = ?, updated_at = ? WHERE type = ? AND pid = ?',\n [newName, this.now().toISOString(), existing.type, existing.pid],\n );\n });\n }\n\n lookup(name: string): RegistryEntry | null {\n return this.findByName(name) ?? null;\n }\n\n list(): RegistryEntry[] {\n const rows = this.db.query<RegistryRow>('SELECT * FROM agents ORDER BY started_at ASC, name ASC');\n return rows.map((row) => this.rowToEntry(row));\n }\n}\n"],"names":["os","path","DatabaseConnection","resolveAgentRegistryDbPath","RenameNotFoundError","Error","agentName","name","RenameConflictError","DEFAULT_REGISTRY_PATH","join","homedir","DEFAULT_PRUNE_INTERVAL_MS","defaultInstance","AgentRegistry","db","now","pruneIntervalMs","lastPrunedAt","filePath","options","Date","dbPath","verbose","onDatabaseOperation","default","rowToEntry","row","type","pid","tmuxSession","tmux_session","cwd","startedAt","started_at","sessionId","session_id","sessionFilePath","session_file_path","mergeEntry","incoming","existing","incomingIsManaged","Boolean","findByIdentity","queryOne","undefined","findByName","findPidConflicts","query","map","entriesEqual","left","right","deleteNameConflict","conflict","isAlive","execute","insertOrUpdate","entry","instance","prepare","run","updatedAt","toISOString","needsWrite","merged","length","save","pidConflicts","process","kill","error","code","pruneAt","nowMs","entries","list","stale","filter","e","transaction","prune","getTime","pruneIfDue","elapsed","register","registerBatch","some","rename","currentName","newName","lookup","rows"],"mappings":"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"}
|
package/package.json
CHANGED
package/src/AgentManager.ts
CHANGED
|
@@ -180,15 +180,18 @@ export class AgentManager {
|
|
|
180
180
|
});
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
const
|
|
183
|
+
const identityKey = (type: string, pid: number): string => `${type}:${pid}`;
|
|
184
|
+
const preExistingByIdentity = new Map(
|
|
185
|
+
this.registry.list().map((entry) => [identityKey(entry.type, entry.pid), entry]),
|
|
186
|
+
);
|
|
184
187
|
const entries = allAgents.map((agent) =>
|
|
185
|
-
this.toRegistryEntry(agent,
|
|
188
|
+
this.toRegistryEntry(agent, preExistingByIdentity.get(identityKey(agent.type, agent.pid))),
|
|
186
189
|
);
|
|
187
190
|
if (entries.length > 0) this.registry.registerBatch(entries);
|
|
188
|
-
this.registry.
|
|
191
|
+
this.registry.pruneIfDue();
|
|
189
192
|
|
|
190
193
|
for (const agent of allAgents) {
|
|
191
|
-
const entry =
|
|
194
|
+
const entry = preExistingByIdentity.get(identityKey(agent.type, agent.pid));
|
|
192
195
|
if (entry) {
|
|
193
196
|
agent.name = entry.name;
|
|
194
197
|
}
|
|
@@ -344,15 +344,25 @@ describe('AgentManager', () => {
|
|
|
344
344
|
let regPath: string;
|
|
345
345
|
let registry: AgentRegistry;
|
|
346
346
|
let scopedManager: AgentManager;
|
|
347
|
+
let nowMs: number;
|
|
348
|
+
let databaseOperations: string[];
|
|
347
349
|
|
|
348
350
|
beforeEach(() => {
|
|
349
351
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-manager-'));
|
|
350
352
|
regPath = path.join(tmpDir, 'agents.json');
|
|
351
|
-
|
|
353
|
+
nowMs = Date.parse('2026-08-14T10:00:00.000Z');
|
|
354
|
+
databaseOperations = [];
|
|
355
|
+
registry = new AgentRegistry(regPath, {
|
|
356
|
+
now: () => new Date(nowMs),
|
|
357
|
+
pruneIntervalMs: 30_000,
|
|
358
|
+
onDatabaseOperation: (sql) => databaseOperations.push(sql),
|
|
359
|
+
});
|
|
352
360
|
scopedManager = new AgentManager(registry);
|
|
361
|
+
databaseOperations = [];
|
|
353
362
|
});
|
|
354
363
|
|
|
355
364
|
afterEach(() => {
|
|
365
|
+
vi.restoreAllMocks();
|
|
356
366
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
357
367
|
});
|
|
358
368
|
|
|
@@ -429,6 +439,35 @@ describe('AgentManager', () => {
|
|
|
429
439
|
expect(registry.list()[0].startedAt).toBe('2026-05-30T00:00:00.000Z');
|
|
430
440
|
});
|
|
431
441
|
|
|
442
|
+
it('preserves custom name and tmux session across two EPERM refresh cycles', async () => {
|
|
443
|
+
registry.register({
|
|
444
|
+
name: 'merry',
|
|
445
|
+
type: 'claude',
|
|
446
|
+
pid: process.pid,
|
|
447
|
+
tmuxSession: 'merry-tmux',
|
|
448
|
+
cwd: '/cwd/merry',
|
|
449
|
+
startedAt: '2026-05-30T00:00:00.000Z',
|
|
450
|
+
sessionId: 'sid-merry',
|
|
451
|
+
sessionFilePath: '/path/merry.jsonl',
|
|
452
|
+
});
|
|
453
|
+
scopedManager.registerAdapter(new MockAdapter('claude', [
|
|
454
|
+
createMockAgent({ name: `ai-devkit-${process.pid}`, pid: process.pid }),
|
|
455
|
+
]));
|
|
456
|
+
vi.spyOn(process, 'kill').mockImplementation(() => {
|
|
457
|
+
throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const firstRefresh = await scopedManager.listAgents();
|
|
461
|
+
const secondRefresh = await scopedManager.listAgents();
|
|
462
|
+
|
|
463
|
+
expect(firstRefresh[0].name).toBe('merry');
|
|
464
|
+
expect(secondRefresh[0].name).toBe('merry');
|
|
465
|
+
expect(registry.lookup('merry')).toMatchObject({
|
|
466
|
+
name: 'merry',
|
|
467
|
+
tmuxSession: 'merry-tmux',
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
432
471
|
it('preserves a user-managed name when a fallback row was written later for the same pid', async () => {
|
|
433
472
|
registry.register({
|
|
434
473
|
name: 'agent-list-debug',
|
|
@@ -495,15 +534,114 @@ describe('AgentManager', () => {
|
|
|
495
534
|
.toEqual(['a', 'b']);
|
|
496
535
|
});
|
|
497
536
|
|
|
498
|
-
it('
|
|
537
|
+
it('performs zero database writes on an unchanged refresh', async () => {
|
|
538
|
+
const adapter = new MockAdapter('claude', [
|
|
539
|
+
createMockAgent({
|
|
540
|
+
name: 'stable',
|
|
541
|
+
pid: process.pid,
|
|
542
|
+
projectPath: '/cwd/stable',
|
|
543
|
+
sessionId: 'stable-session',
|
|
544
|
+
sessionFilePath: '/sessions/stable.jsonl',
|
|
545
|
+
}),
|
|
546
|
+
]);
|
|
547
|
+
scopedManager.registerAdapter(adapter);
|
|
548
|
+
await scopedManager.listAgents();
|
|
549
|
+
databaseOperations = [];
|
|
550
|
+
|
|
551
|
+
await scopedManager.listAgents();
|
|
552
|
+
|
|
553
|
+
const writes = databaseOperations.filter((sql) => /^\s*(BEGIN|COMMIT|INSERT|UPDATE|DELETE)/i.test(sql));
|
|
554
|
+
expect(writes).toEqual([]);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('persists changed fields once in one write transaction', async () => {
|
|
558
|
+
const adapter = new MockAdapter('claude', [
|
|
559
|
+
createMockAgent({ name: 'changing', pid: process.pid, projectPath: '/cwd/before' }),
|
|
560
|
+
]);
|
|
561
|
+
scopedManager.registerAdapter(adapter);
|
|
562
|
+
await scopedManager.listAgents();
|
|
563
|
+
databaseOperations = [];
|
|
564
|
+
nowMs += 1_000;
|
|
565
|
+
adapter.setAgents([
|
|
566
|
+
createMockAgent({ name: 'changing', pid: process.pid, projectPath: '/cwd/after' }),
|
|
567
|
+
]);
|
|
568
|
+
|
|
569
|
+
await scopedManager.listAgents();
|
|
570
|
+
|
|
571
|
+
const upserts = databaseOperations.filter((sql) => /^\s*INSERT INTO agents/i.test(sql));
|
|
572
|
+
const transactions = databaseOperations.filter((sql) => /^\s*(BEGIN|COMMIT)/i.test(sql));
|
|
573
|
+
expect(upserts).toHaveLength(1);
|
|
574
|
+
expect(upserts[0]).toContain("'2026-08-14T10:00:01.000Z'");
|
|
575
|
+
expect(transactions).toHaveLength(2);
|
|
576
|
+
expect(registry.lookup('changing')?.cwd).toBe('/cwd/after');
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('prunes newly dead entries only when the passive cadence is due', async () => {
|
|
580
|
+
registry.register({
|
|
581
|
+
name: 'cadenced',
|
|
582
|
+
type: 'claude',
|
|
583
|
+
pid: process.pid,
|
|
584
|
+
tmuxSession: '',
|
|
585
|
+
cwd: '/cwd/cadenced',
|
|
586
|
+
startedAt: '2026-05-30T00:00:00.000Z',
|
|
587
|
+
sessionId: 'sid-cadenced',
|
|
588
|
+
sessionFilePath: '',
|
|
589
|
+
});
|
|
590
|
+
const alive = vi.spyOn(registry, 'isAlive').mockReturnValue(true);
|
|
591
|
+
|
|
592
|
+
await scopedManager.listAgents();
|
|
593
|
+
expect(alive).toHaveBeenCalledTimes(1);
|
|
594
|
+
alive.mockReturnValue(false);
|
|
595
|
+
nowMs += 29_999;
|
|
596
|
+
|
|
597
|
+
await scopedManager.listAgents();
|
|
598
|
+
expect(alive).toHaveBeenCalledTimes(1);
|
|
599
|
+
expect(registry.lookup('cadenced')).not.toBeNull();
|
|
600
|
+
|
|
601
|
+
nowMs += 1;
|
|
602
|
+
await scopedManager.listAgents();
|
|
603
|
+
expect(alive).toHaveBeenCalledTimes(2);
|
|
604
|
+
expect(registry.lookup('cadenced')).toBeNull();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('does not inherit a name when the same pid is reused by another agent type', async () => {
|
|
608
|
+
registry.register({
|
|
609
|
+
name: 'old-claude',
|
|
610
|
+
type: 'claude',
|
|
611
|
+
pid: process.pid,
|
|
612
|
+
tmuxSession: 'old-claude',
|
|
613
|
+
cwd: '/cwd/old',
|
|
614
|
+
startedAt: '2026-05-30T00:00:00.000Z',
|
|
615
|
+
sessionId: 'old-session',
|
|
616
|
+
sessionFilePath: '',
|
|
617
|
+
});
|
|
618
|
+
scopedManager.registerAdapter(new MockAdapter('codex', [
|
|
619
|
+
createMockAgent({
|
|
620
|
+
name: 'new-codex',
|
|
621
|
+
type: 'codex',
|
|
622
|
+
pid: process.pid,
|
|
623
|
+
projectPath: '/cwd/new',
|
|
624
|
+
sessionId: 'new-session',
|
|
625
|
+
}),
|
|
626
|
+
]));
|
|
627
|
+
|
|
628
|
+
const agents = await scopedManager.listAgents();
|
|
629
|
+
|
|
630
|
+
expect(agents[0].name).toBe('new-codex');
|
|
631
|
+
expect(registry.lookup('old-claude')).toBeNull();
|
|
632
|
+
expect(registry.lookup('new-codex')).toMatchObject({ type: 'codex', pid: process.pid });
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('skips registerBatch when no agents are detected and prune is not due', async () => {
|
|
499
636
|
const writeSpy = vi.spyOn(registry, 'registerBatch');
|
|
500
|
-
const pruneSpy = vi.spyOn(registry, '
|
|
637
|
+
const pruneSpy = vi.spyOn(registry, 'pruneIfDue');
|
|
501
638
|
|
|
502
639
|
scopedManager.registerAdapter(new MockAdapter('claude', []));
|
|
503
640
|
await scopedManager.listAgents();
|
|
641
|
+
await scopedManager.listAgents();
|
|
504
642
|
|
|
505
643
|
expect(writeSpy).not.toHaveBeenCalled();
|
|
506
|
-
expect(pruneSpy).toHaveBeenCalledTimes(
|
|
644
|
+
expect(pruneSpy).toHaveBeenCalledTimes(2);
|
|
507
645
|
});
|
|
508
646
|
});
|
|
509
647
|
|
|
@@ -29,6 +29,7 @@ describe('AgentRegistry', () => {
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
afterEach(() => {
|
|
32
|
+
vi.restoreAllMocks();
|
|
32
33
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
33
34
|
});
|
|
34
35
|
|
|
@@ -81,6 +82,19 @@ describe('AgentRegistry', () => {
|
|
|
81
82
|
expect(registry.lookup(`ai-devkit-${process.pid}`)).toBeNull();
|
|
82
83
|
expect(registry.list()).toHaveLength(1);
|
|
83
84
|
});
|
|
85
|
+
|
|
86
|
+
it('preserves an existing name conflict when its probe fails with EPERM', () => {
|
|
87
|
+
registry.register(makeEntry({ name: 'claimed-name', pid: process.pid }));
|
|
88
|
+
vi.spyOn(process, 'kill').mockImplementation(() => {
|
|
89
|
+
throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(() => registry.register(makeEntry({
|
|
93
|
+
name: 'claimed-name',
|
|
94
|
+
pid: process.pid + 1,
|
|
95
|
+
}))).toThrow();
|
|
96
|
+
expect(registry.lookup('claimed-name')?.pid).toBe(process.pid);
|
|
97
|
+
});
|
|
84
98
|
});
|
|
85
99
|
|
|
86
100
|
describe('registerBatch', () => {
|
|
@@ -118,6 +132,33 @@ describe('AgentRegistry', () => {
|
|
|
118
132
|
expect(registry.list()).toHaveLength(1);
|
|
119
133
|
expect(registry.lookup('custom-name')?.pid).toBe(process.pid);
|
|
120
134
|
});
|
|
135
|
+
|
|
136
|
+
it('cleans up a cross-type row when its pid has been reused', () => {
|
|
137
|
+
registry.register(makeEntry({ name: 'old-claude', type: 'claude', pid: process.pid }));
|
|
138
|
+
|
|
139
|
+
registry.register(makeEntry({
|
|
140
|
+
name: 'new-codex',
|
|
141
|
+
type: 'codex',
|
|
142
|
+
pid: process.pid,
|
|
143
|
+
tmuxSession: '',
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
expect(registry.lookup('old-claude')).toBeNull();
|
|
147
|
+
expect(registry.lookup('new-codex')).toMatchObject({ type: 'codex', pid: process.pid });
|
|
148
|
+
expect(registry.list()).toHaveLength(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('rolls back the whole batch when a live name conflict rejects one entry', () => {
|
|
152
|
+
registry.register(makeEntry({ name: 'taken', pid: process.pid }));
|
|
153
|
+
|
|
154
|
+
expect(() => registry.registerBatch([
|
|
155
|
+
makeEntry({ name: 'fresh', pid: 999998 }),
|
|
156
|
+
makeEntry({ name: 'taken', type: 'codex', pid: 999997 }),
|
|
157
|
+
])).toThrow(/UNIQUE constraint failed/);
|
|
158
|
+
|
|
159
|
+
expect(registry.lookup('fresh')).toBeNull();
|
|
160
|
+
expect(registry.lookup('taken')?.pid).toBe(process.pid);
|
|
161
|
+
});
|
|
121
162
|
});
|
|
122
163
|
|
|
123
164
|
describe('lookup', () => {
|
|
@@ -157,6 +198,30 @@ describe('AgentRegistry', () => {
|
|
|
157
198
|
it('returns false for a PID that does not exist', () => {
|
|
158
199
|
expect(registry.isAlive(makeEntry({ pid: 999999 }))).toBe(false);
|
|
159
200
|
});
|
|
201
|
+
|
|
202
|
+
it('returns true when the process probe is forbidden with EPERM', () => {
|
|
203
|
+
vi.spyOn(process, 'kill').mockImplementation(() => {
|
|
204
|
+
throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(registry.isAlive(makeEntry())).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('returns false when the process probe reports ESRCH', () => {
|
|
211
|
+
vi.spyOn(process, 'kill').mockImplementation(() => {
|
|
212
|
+
throw Object.assign(new Error('no such process'), { code: 'ESRCH' });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(registry.isAlive(makeEntry())).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('returns true when the process probe fails without a definitive error code', () => {
|
|
219
|
+
vi.spyOn(process, 'kill').mockImplementation(() => {
|
|
220
|
+
throw new Error('indeterminate probe failure');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(registry.isAlive(makeEntry())).toBe(true);
|
|
224
|
+
});
|
|
160
225
|
});
|
|
161
226
|
|
|
162
227
|
describe('prune', () => {
|
|
@@ -177,9 +242,52 @@ describe('AgentRegistry', () => {
|
|
|
177
242
|
expect(after).toEqual(before);
|
|
178
243
|
});
|
|
179
244
|
|
|
245
|
+
it('preserves entries when liveness probing fails with EPERM', () => {
|
|
246
|
+
registry.register(makeEntry({ name: 'custom-name', tmuxSession: 'tmux-custom' }));
|
|
247
|
+
vi.spyOn(process, 'kill').mockImplementation(() => {
|
|
248
|
+
throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
registry.prune();
|
|
252
|
+
|
|
253
|
+
expect(registry.lookup('custom-name')).toMatchObject({
|
|
254
|
+
name: 'custom-name',
|
|
255
|
+
tmuxSession: 'tmux-custom',
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('removes entries when liveness probing fails with ESRCH', () => {
|
|
260
|
+
registry.register(makeEntry({ name: 'dead' }));
|
|
261
|
+
vi.spyOn(process, 'kill').mockImplementation(() => {
|
|
262
|
+
throw Object.assign(new Error('no such process'), { code: 'ESRCH' });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
registry.prune();
|
|
266
|
+
|
|
267
|
+
expect(registry.lookup('dead')).toBeNull();
|
|
268
|
+
});
|
|
269
|
+
|
|
180
270
|
it('does nothing when file is missing', () => {
|
|
181
271
|
expect(() => registry.prune()).not.toThrow();
|
|
182
272
|
});
|
|
273
|
+
|
|
274
|
+
it('keeps forced prune available before the passive cadence is due', () => {
|
|
275
|
+
let nowMs = Date.parse('2026-08-14T10:00:00.000Z');
|
|
276
|
+
const clocked = new AgentRegistry(regPath, {
|
|
277
|
+
now: () => new Date(nowMs),
|
|
278
|
+
pruneIntervalMs: 30_000,
|
|
279
|
+
});
|
|
280
|
+
clocked.register(makeEntry({ name: 'forced', pid: process.pid }));
|
|
281
|
+
const alive = vi.spyOn(clocked, 'isAlive').mockReturnValue(true);
|
|
282
|
+
clocked.pruneIfDue();
|
|
283
|
+
alive.mockReturnValue(false);
|
|
284
|
+
nowMs += 1;
|
|
285
|
+
|
|
286
|
+
clocked.prune();
|
|
287
|
+
|
|
288
|
+
expect(alive).toHaveBeenCalledTimes(2);
|
|
289
|
+
expect(clocked.lookup('forced')).toBeNull();
|
|
290
|
+
});
|
|
183
291
|
});
|
|
184
292
|
|
|
185
293
|
describe('default()', () => {
|
|
@@ -215,6 +323,17 @@ describe('AgentRegistry', () => {
|
|
|
215
323
|
expect(() => registry.rename('agent-a', 'agent-b')).toThrow(RenameConflictError);
|
|
216
324
|
});
|
|
217
325
|
|
|
326
|
+
it('throws RenameConflictError when the conflicting entry probe fails with EPERM', () => {
|
|
327
|
+
registry.register(makeEntry({ name: 'agent-a', pid: process.pid }));
|
|
328
|
+
registry.register(makeEntry({ name: 'agent-b', pid: process.ppid }));
|
|
329
|
+
vi.spyOn(process, 'kill').mockImplementation(() => {
|
|
330
|
+
throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
expect(() => registry.rename('agent-a', 'agent-b')).toThrow(RenameConflictError);
|
|
334
|
+
expect(registry.lookup('agent-b')?.pid).toBe(process.ppid);
|
|
335
|
+
});
|
|
336
|
+
|
|
218
337
|
it('succeeds when new name exists only as a stale (dead) entry', () => {
|
|
219
338
|
registry.register(makeEntry({ name: 'agent-a', pid: process.pid }));
|
|
220
339
|
registry.register(makeEntry({ name: 'agent-b', pid: 999999 }));
|
|
@@ -8,7 +8,7 @@ export const DEFAULT_AGENT_REGISTRY_DB_PATH = join(homedir(), '.ai-devkit', 'age
|
|
|
8
8
|
|
|
9
9
|
export interface DatabaseOptions {
|
|
10
10
|
dbPath?: string;
|
|
11
|
-
verbose?: boolean;
|
|
11
|
+
verbose?: boolean | ((message: string) => void);
|
|
12
12
|
readonly?: boolean;
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -27,7 +27,9 @@ export class DatabaseConnection {
|
|
|
27
27
|
|
|
28
28
|
this.db = new Database(this.dbPath, {
|
|
29
29
|
readonly: options.readonly ?? false,
|
|
30
|
-
verbose: options.verbose
|
|
30
|
+
verbose: typeof options.verbose === 'function'
|
|
31
|
+
? options.verbose
|
|
32
|
+
: options.verbose ? console.log : undefined,
|
|
31
33
|
});
|
|
32
34
|
|
|
33
35
|
this.configure();
|
package/src/index.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type { AgentSortKey } from './utils/sortAgents.js';
|
|
|
29
29
|
export type { ListAgentsOptions } from './AgentManager.js';
|
|
30
30
|
|
|
31
31
|
export { AgentRegistry, RenameNotFoundError, RenameConflictError } from './utils/AgentRegistry.js';
|
|
32
|
-
export type { RegistryEntry } from './utils/AgentRegistry.js';
|
|
32
|
+
export type { AgentRegistryOptions, RegistryEntry } from './utils/AgentRegistry.js';
|
|
33
33
|
export { TmuxManager } from './terminal/TmuxManager.js';
|
|
34
34
|
export { AGENTS } from './utils/agents.js';
|
|
35
35
|
export type { AgentConfig, StartableAgentType } from './utils/agents.js';
|
|
@@ -44,14 +44,29 @@ interface RegistryRow {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
const DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'agents.json');
|
|
47
|
+
const DEFAULT_PRUNE_INTERVAL_MS = 30_000;
|
|
47
48
|
|
|
48
49
|
let defaultInstance: AgentRegistry | null = null;
|
|
49
50
|
|
|
51
|
+
export interface AgentRegistryOptions {
|
|
52
|
+
now?: () => Date;
|
|
53
|
+
pruneIntervalMs?: number;
|
|
54
|
+
onDatabaseOperation?: (sql: string) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
export class AgentRegistry {
|
|
51
58
|
private db: DatabaseConnection;
|
|
59
|
+
private readonly now: () => Date;
|
|
60
|
+
private readonly pruneIntervalMs: number;
|
|
61
|
+
private lastPrunedAt: number | undefined;
|
|
52
62
|
|
|
53
|
-
constructor(filePath: string = DEFAULT_REGISTRY_PATH) {
|
|
54
|
-
this.
|
|
63
|
+
constructor(filePath: string = DEFAULT_REGISTRY_PATH, options: AgentRegistryOptions = {}) {
|
|
64
|
+
this.now = options.now ?? (() => new Date());
|
|
65
|
+
this.pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS;
|
|
66
|
+
this.db = new DatabaseConnection({
|
|
67
|
+
dbPath: resolveAgentRegistryDbPath(filePath),
|
|
68
|
+
verbose: options.onDatabaseOperation,
|
|
69
|
+
});
|
|
55
70
|
}
|
|
56
71
|
|
|
57
72
|
static default(): AgentRegistry {
|
|
@@ -101,6 +116,24 @@ export class AgentRegistry {
|
|
|
101
116
|
return row ? this.rowToEntry(row) : undefined;
|
|
102
117
|
}
|
|
103
118
|
|
|
119
|
+
private findPidConflicts(type: AgentType, pid: number): RegistryEntry[] {
|
|
120
|
+
return this.db.query<RegistryRow>(
|
|
121
|
+
'SELECT * FROM agents WHERE pid = ? AND type <> ?',
|
|
122
|
+
[pid, type],
|
|
123
|
+
).map((row) => this.rowToEntry(row));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private entriesEqual(left: RegistryEntry, right: RegistryEntry): boolean {
|
|
127
|
+
return left.name === right.name
|
|
128
|
+
&& left.type === right.type
|
|
129
|
+
&& left.pid === right.pid
|
|
130
|
+
&& left.tmuxSession === right.tmuxSession
|
|
131
|
+
&& left.cwd === right.cwd
|
|
132
|
+
&& left.startedAt === right.startedAt
|
|
133
|
+
&& left.sessionId === right.sessionId
|
|
134
|
+
&& left.sessionFilePath === right.sessionFilePath;
|
|
135
|
+
}
|
|
136
|
+
|
|
104
137
|
private deleteNameConflict(name: string, type: AgentType, pid: number): void {
|
|
105
138
|
const conflict = this.findByName(name);
|
|
106
139
|
if (!conflict) return;
|
|
@@ -126,31 +159,66 @@ export class AgentRegistry {
|
|
|
126
159
|
session_id = excluded.session_id,
|
|
127
160
|
session_file_path = excluded.session_file_path,
|
|
128
161
|
updated_at = excluded.updated_at
|
|
129
|
-
`).run({ ...entry, updatedAt:
|
|
162
|
+
`).run({ ...entry, updatedAt: this.now().toISOString() });
|
|
130
163
|
}
|
|
131
164
|
|
|
132
|
-
private
|
|
133
|
-
this.
|
|
134
|
-
this.
|
|
165
|
+
private needsWrite(incoming: RegistryEntry): boolean {
|
|
166
|
+
const existing = this.findByIdentity(incoming.type, incoming.pid);
|
|
167
|
+
const merged = this.mergeEntry(incoming, existing);
|
|
168
|
+
return !existing
|
|
169
|
+
|| !this.entriesEqual(merged, existing)
|
|
170
|
+
|| this.findPidConflicts(incoming.type, incoming.pid).length > 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private save(incoming: RegistryEntry): void {
|
|
174
|
+
const existing = this.findByIdentity(incoming.type, incoming.pid);
|
|
175
|
+
const merged = this.mergeEntry(incoming, existing);
|
|
176
|
+
const pidConflicts = this.findPidConflicts(incoming.type, incoming.pid);
|
|
177
|
+
if (existing && this.entriesEqual(merged, existing) && pidConflicts.length === 0) return;
|
|
178
|
+
|
|
179
|
+
for (const conflict of pidConflicts) {
|
|
180
|
+
this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);
|
|
181
|
+
}
|
|
182
|
+
if (existing && this.entriesEqual(merged, existing)) return;
|
|
183
|
+
|
|
184
|
+
this.deleteNameConflict(merged.name, merged.type, merged.pid);
|
|
185
|
+
this.insertOrUpdate(merged);
|
|
135
186
|
}
|
|
136
187
|
|
|
137
188
|
isAlive(entry: RegistryEntry): boolean {
|
|
138
189
|
try {
|
|
139
190
|
process.kill(entry.pid, 0);
|
|
140
191
|
return true;
|
|
141
|
-
} catch {
|
|
142
|
-
|
|
192
|
+
} catch (error) {
|
|
193
|
+
const code = error && typeof error === 'object' && 'code' in error
|
|
194
|
+
? error.code
|
|
195
|
+
: undefined;
|
|
196
|
+
return code !== 'ESRCH';
|
|
143
197
|
}
|
|
144
198
|
}
|
|
145
199
|
|
|
146
|
-
|
|
200
|
+
private pruneAt(nowMs: number): void {
|
|
147
201
|
const entries = this.list();
|
|
148
202
|
const stale = entries.filter((e) => !this.isAlive(e));
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
203
|
+
if (stale.length > 0) {
|
|
204
|
+
this.db.transaction(() => {
|
|
205
|
+
for (const entry of stale) {
|
|
206
|
+
this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [entry.type, entry.pid]);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
this.lastPrunedAt = nowMs;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
prune(): void {
|
|
214
|
+
this.pruneAt(this.now().getTime());
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
pruneIfDue(): void {
|
|
218
|
+
const nowMs = this.now().getTime();
|
|
219
|
+
const elapsed = this.lastPrunedAt === undefined ? undefined : nowMs - this.lastPrunedAt;
|
|
220
|
+
if (elapsed !== undefined && elapsed >= 0 && elapsed < this.pruneIntervalMs) return;
|
|
221
|
+
this.pruneAt(nowMs);
|
|
154
222
|
}
|
|
155
223
|
|
|
156
224
|
register(entry: RegistryEntry): void {
|
|
@@ -159,10 +227,10 @@ export class AgentRegistry {
|
|
|
159
227
|
|
|
160
228
|
registerBatch(entries: RegistryEntry[]): void {
|
|
161
229
|
if (entries.length === 0) return;
|
|
230
|
+
if (!entries.some((entry) => this.needsWrite(entry))) return;
|
|
162
231
|
this.db.transaction(() => {
|
|
163
232
|
for (const incoming of entries) {
|
|
164
|
-
|
|
165
|
-
this.save(this.mergeEntry(incoming, existing));
|
|
233
|
+
this.save(incoming);
|
|
166
234
|
}
|
|
167
235
|
});
|
|
168
236
|
}
|
|
@@ -183,7 +251,7 @@ export class AgentRegistry {
|
|
|
183
251
|
}
|
|
184
252
|
this.db.execute(
|
|
185
253
|
'UPDATE agents SET name = ?, updated_at = ? WHERE type = ? AND pid = ?',
|
|
186
|
-
[newName,
|
|
254
|
+
[newName, this.now().toISOString(), existing.type, existing.pid],
|
|
187
255
|
);
|
|
188
256
|
});
|
|
189
257
|
}
|