Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
phase: design
title: Agent Registry Wrapper Transfer Design
description: Atomically reconcile same-type name ownership during discovery batches
---

# Agent Registry Wrapper Transfer Design

## Design

`registerBatch()` treats an incoming row whose name is owned by another PID of the same agent type as an identity transfer. It merges from the old name owner, assigns the incoming type and PID, deletes the old identity and any existing target-PID fallback, then saves the merged row in the same transaction.

Transfer candidates are snapshotted before processing the batch. A duplicate name introduced by two incoming rows is therefore not mistaken for a wrapper handoff and still rolls back the batch with a uniqueness error.

The merge preserves the old name, tmux session, and start time while preferring non-empty incoming cwd and session fields.

## Safety Boundary

- Transfer is available only through `registerBatch()`, the live-discovery write path.
- `register()` retains ordinary same-identity merge and unique-name enforcement.
- A name owned by another agent type is not transferable and remains a constraint error.
- A name created earlier in the same batch is not transferable.

## Components

- `AgentRegistry.mergeEntry()` must assign incoming identity fields when merging across PIDs.
- `AgentRegistry.registerBatch()` detects and applies transfers atomically.
- Registry unit tests cover transfers with and without a pre-existing child row.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
phase: implementation
title: Agent Registry Wrapper Transfer Implementation
description: Implementation notes for batch identity handoff in AgentRegistry
---

# Agent Registry Wrapper Transfer Implementation

## Changed Files

- `packages/agent-manager/src/utils/AgentRegistry.ts`
- Keeps `register()` on the normal same-PID merge path.
- Resolves a same-name, same-type transfer source during `registerBatch()`.
- Snapshots transfer candidates before iteration so new batch rows cannot become transfer sources.
- Deletes the source and any target fallback before saving the merged child row.
- Assigns incoming type and PID when merging entries across identities.
- `packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts`
- Covers direct transfer, transfer over a cached child fallback, and same-batch duplicate rejection.

## Behavior

The registry remains the single writer during list refresh. Wrapper-aware adapters can return the child PID with the wrapper's name, and the registry completes the handoff without adapter-side persistence or public API changes.

## Edge Cases

- Existing child fallback: removed before insertion so the wrapper start time is retained.
- Cross-type owner: not transferred.
- Same-batch duplicate: rejected and rolled back.
- Single registration: does not claim another live PID's name.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
phase: planning
title: Agent Registry Wrapper Transfer Plan
description: TDD and delivery tasks for wrapper-to-child identity reconciliation
---

# Agent Registry Wrapper Transfer Plan

## Tasks

- [x] Reproduce the live Gemini wrapper/child unique-name collision.
- [x] Add a failing registry test for direct wrapper-to-child transfer.
- [x] Implement minimal same-type batch identity transfer.
- [x] Add a failing test for transfer over an existing child fallback.
- [x] Preserve managed metadata and current child session data.
- [x] Keep duplicate names introduced within one batch as strict constraint failures.
- [x] Prove the regressions fail without the production fix and pass with it restored.
- [x] Run focused tests, full agent-manager tests, CLI tests, lint, typecheck, build, and a local CLI smoke test.
- [x] Prepare a validated, review-ready branch for commit and publication.

## Risks

- An overly broad transfer could hide unrelated name collisions. Mitigation: restrict transfer to batch writes and same-type owners.
- Replacing a cached child row could lose wrapper metadata. Mitigation: merge from the wrapper owner and replace both identities atomically.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
phase: requirements
title: Agent Registry Wrapper Transfer Requirements
description: Prevent live wrapper-to-child transitions from violating unique agent names
---

# Agent Registry Wrapper Transfer Requirements

## Problem

Gemini CLI runs a Node wrapper and child process. Detection returns the child PID while carrying the wrapper's registry name. If the live wrapper row still owns that name, `AgentManager.listAgents()` writes the child row and SQLite rejects it with `UNIQUE constraint failed: agents.name`.

## Goals

- Let batch discovery transfer a registry name between PIDs of the same agent type.
- Preserve the wrapper's managed name, tmux session, and original start time.
- Use the detected child's PID and current session metadata.
- Handle transitions both before and after a child fallback row is cached.
- Keep explicit single-entry registration and cross-type name conflicts strict.
- Reject duplicate names first introduced by separate entries in the same discovery batch.

## Success Criteria

- Both wrapper-to-child orderings complete atomically with one target-PID row.
- `agent list` no longer fails during the reproduced Gemini transition.
- Existing registry, manager, adapter, and CLI tests remain green.

## Constraints

- Keep the public `AgentRegistry` API unchanged.
- Do not add adapter-specific registry writes.
- Keep all deletes and insertion within the existing SQLite batch transaction.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
phase: testing
title: Agent Registry Wrapper Transfer Testing
description: Regression and integration evidence for wrapper-to-child registry transfers
---

# Agent Registry Wrapper Transfer Testing

## Regression Cases

- [x] A live wrapper name transfers to a newly detected child PID.
- [x] A live wrapper name replaces an existing child fallback row.
- [x] Managed tmux metadata and original start time survive transfer.
- [x] Incoming child session ID and file path replace stale wrapper metadata.
- [x] Duplicate names introduced within one batch remain constraint failures and roll back.
- [x] Removing the production change restores both regression failures, including the SQLite uniqueness error.

## Validation

- [x] Focused registry suite: 30 passed.
- [x] Full post-rebase agent-manager coverage suite with process access: 521 passed; 89.03% statements, 78.01% branches, 95.94% functions, and 92.3% lines overall.
- [x] `AgentRegistry.ts` coverage: 98.64% statements and 92.85% branches.
- [x] CLI agent service and command tests: 104 passed.
- [x] Agent-manager typecheck and lint passed.
- [x] Monorepo build passed for all six projects.
- [x] Built local `agent list --json` completed against the previously failing local registry.
- [x] Base and feature docs lint passed.
86 changes: 86 additions & 0 deletions packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,92 @@ describe('AgentRegistry', () => {
expect(registry.list()).toHaveLength(1);
expect(registry.lookup('custom-name')?.pid).toBe(process.pid);
});

it('transfers a same-type name from a live wrapper pid to its detected child pid', () => {
registry.register(makeEntry({
name: 'managed-gemini',
type: 'gemini_cli',
pid: process.ppid,
tmuxSession: 'managed-gemini',
startedAt: '2026-05-30T00:00:00.000Z',
sessionId: `pid-${process.ppid}`,
sessionFilePath: '',
}));

registry.registerBatch([makeEntry({
name: 'managed-gemini',
type: 'gemini_cli',
pid: process.pid,
tmuxSession: '',
startedAt: '2026-05-31T00:00:00.000Z',
sessionId: 'gemini-session',
sessionFilePath: '/tmp/gemini-session.json',
})]);

expect(registry.list()).toEqual([
expect.objectContaining({
name: 'managed-gemini',
type: 'gemini_cli',
pid: process.pid,
tmuxSession: 'managed-gemini',
startedAt: '2026-05-30T00:00:00.000Z',
sessionId: 'gemini-session',
sessionFilePath: '/tmp/gemini-session.json',
}),
]);
});

it('replaces an existing child fallback when transferring its live wrapper name', () => {
registry.register(makeEntry({
name: 'managed-gemini',
type: 'gemini_cli',
pid: process.ppid,
tmuxSession: 'managed-gemini',
startedAt: '2026-05-30T00:00:00.000Z',
sessionId: `pid-${process.ppid}`,
sessionFilePath: '',
}));
registry.register(makeEntry({
name: `ai-devkit-${process.pid}`,
type: 'gemini_cli',
pid: process.pid,
tmuxSession: '',
startedAt: '2026-05-31T00:00:00.000Z',
sessionId: `pid-${process.pid}`,
sessionFilePath: '',
}));

registry.registerBatch([makeEntry({
name: 'managed-gemini',
type: 'gemini_cli',
pid: process.pid,
tmuxSession: '',
startedAt: '2026-06-01T00:00:00.000Z',
sessionId: 'gemini-session',
sessionFilePath: '/tmp/gemini-session.json',
})]);

expect(registry.list()).toEqual([
expect.objectContaining({
name: 'managed-gemini',
type: 'gemini_cli',
pid: process.pid,
tmuxSession: 'managed-gemini',
startedAt: '2026-05-30T00:00:00.000Z',
sessionId: 'gemini-session',
sessionFilePath: '/tmp/gemini-session.json',
}),
]);
});

it('rejects duplicate names introduced within the same batch', () => {
expect(() => registry.registerBatch([
makeEntry({ name: 'duplicate', pid: process.pid }),
makeEntry({ name: 'duplicate', pid: process.ppid }),
])).toThrow(/UNIQUE constraint failed: agents\.name/);

expect(registry.list()).toEqual([]);
});
});

describe('lookup', () => {
Expand Down
30 changes: 28 additions & 2 deletions packages/agent-manager/src/utils/AgentRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export class AgentRegistry {
const incomingIsManaged = Boolean(incoming.tmuxSession);
return {
...existing,
type: incoming.type,
pid: incoming.pid,
name: incomingIsManaged ? incoming.name : existing.name,
tmuxSession: incoming.tmuxSession || existing.tmuxSession,
cwd: incoming.cwd || existing.cwd,
Expand Down Expand Up @@ -157,15 +159,39 @@ export class AgentRegistry {
}

register(entry: RegistryEntry): void {
this.registerBatch([entry]);
this.db.transaction(() => {
const existing = this.findByIdentity(entry.type, entry.pid);
this.save(this.mergeEntry(entry, existing));
});
}

registerBatch(entries: RegistryEntry[]): void {
if (entries.length === 0) return;
this.db.transaction(() => {
const preExistingByName = new Map(this.list().map((entry) => [entry.name, entry]));
for (const incoming of entries) {
const existing = this.findByIdentity(incoming.type, incoming.pid);
this.save(this.mergeEntry(incoming, existing));
const nameOwner = preExistingByName.get(incoming.name);
const transferSource = nameOwner?.type === incoming.type
&& nameOwner.pid !== incoming.pid
? nameOwner
: undefined;
const merged = this.mergeEntry(incoming, transferSource ?? existing);

if (transferSource) {
this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [
transferSource.type,
transferSource.pid,
]);
if (existing) {
this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [
existing.type,
existing.pid,
]);
}
}

this.save(merged);
}
});
}
Expand Down
Loading