Skip to content

Commit 8a943ff

Browse files
committed
Fix wrapper PID handling for Gemini and Copilot agents
1 parent bd40286 commit 8a943ff

8 files changed

Lines changed: 412 additions & 57 deletions

File tree

‎packages/agent-manager/src/__tests__/adapters/CopilotAdapter.test.ts‎

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,16 @@ import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
1212
import { AgentStatus } from '../../adapters/AgentAdapter.js';
1313
import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
1414
import { generateAgentName } from '../../utils/matching.js';
15-
16-
vi.mock('../../utils/process.js', () => ({
17-
listAgentProcesses: vi.fn(),
18-
enrichProcesses: vi.fn(),
19-
}));
15+
import { AgentRegistry } from '../../utils/AgentRegistry.js';
16+
17+
vi.mock('../../utils/process.js', async (importOriginal) => {
18+
const actual = await importOriginal() as typeof import('../../utils/process.js');
19+
return {
20+
...actual,
21+
listAgentProcesses: vi.fn(),
22+
enrichProcesses: vi.fn(),
23+
};
24+
});
2025

2126
vi.mock('../../utils/matching.js', () => ({
2227
generateAgentName: vi.fn(),
@@ -238,6 +243,37 @@ describe('CopilotAdapter', () => {
238243
});
239244
});
240245

246+
it('carries the managed wrapper name to a process-only child before the session lock exists', async () => {
247+
const registry = new AgentRegistry(path.join(tmpDir, 'agents.json'));
248+
adapter = new CopilotAdapter(registry);
249+
(adapter as any).sessionStateDir = sessionStateDir;
250+
const processes: ProcessInfo[] = [
251+
{ pid: 86800, command: 'copilot', cwd: '/repo', tty: 'ttys001', ppid: 84174 },
252+
{ pid: 86810, command: '/custom/install/copilot', cwd: '/repo', tty: 'ttys001', ppid: 86800 },
253+
];
254+
registry.register({
255+
name: 'copilot-started',
256+
type: 'copilot',
257+
pid: 86800,
258+
tmuxSession: 'copilot-started',
259+
cwd: '/repo',
260+
startedAt: '2026-06-13T19:15:16.211Z',
261+
sessionId: 'pid-86800',
262+
sessionFilePath: '',
263+
});
264+
mockedListAgentProcesses.mockReturnValue(processes);
265+
mockedEnrichProcesses.mockReturnValue(processes);
266+
267+
const agents = await adapter.detectAgents();
268+
269+
expect(agents).toHaveLength(1);
270+
expect(agents[0]).toMatchObject({
271+
name: 'copilot-started',
272+
pid: 86810,
273+
sessionId: 'pid-86810',
274+
});
275+
});
276+
241277
it('does not add duplicate process-only agent for wrapper process in the same terminal', async () => {
242278
const processes: ProcessInfo[] = [
243279
{ pid: 14095, command: 'copilot', cwd: '/repo', tty: 'ttys001' },
@@ -262,6 +298,44 @@ describe('CopilotAdapter', () => {
262298
});
263299
});
264300

301+
it('carries the managed wrapper name to a lock-backed child process', async () => {
302+
const registry = new AgentRegistry(path.join(tmpDir, 'agents.json'));
303+
adapter = new CopilotAdapter(registry);
304+
(adapter as any).sessionStateDir = sessionStateDir;
305+
const processes: ProcessInfo[] = [
306+
{ pid: 14095, command: 'copilot', cwd: '/repo', tty: 'ttys001', ppid: 84174 },
307+
{ pid: 14096, command: '/opt/homebrew/Caskroom/copilot-cli/1.0.60/copilot', cwd: '/repo', tty: 'ttys001', ppid: 14095 },
308+
];
309+
registry.register({
310+
name: 'copilot-started',
311+
type: 'copilot',
312+
pid: 14095,
313+
tmuxSession: 'copilot-started',
314+
cwd: '/repo',
315+
startedAt: '2026-06-13T19:15:16.211Z',
316+
sessionId: 'pid-14095',
317+
sessionFilePath: '',
318+
});
319+
mockedListAgentProcesses.mockReturnValue(processes);
320+
mockedEnrichProcesses.mockReturnValue(processes);
321+
writeSession('sess-wrapper', {
322+
lockPid: 14096,
323+
events: [
324+
sessionStart('sess-wrapper', '/repo', '2026-06-09T09:50:00.000Z'),
325+
{ type: 'user.message', data: { content: 'hello' }, timestamp: new Date().toISOString() },
326+
],
327+
});
328+
329+
const agents = await adapter.detectAgents();
330+
331+
expect(agents).toHaveLength(1);
332+
expect(agents[0]).toMatchObject({
333+
name: 'copilot-started',
334+
pid: 14096,
335+
sessionId: 'sess-wrapper',
336+
});
337+
});
338+
265339
it('uses workspace metadata when events are missing', async () => {
266340
const processes: ProcessInfo[] = [
267341
{ pid: 300, command: 'copilot', cwd: '/proc-cwd', tty: 'ttys003' },

‎packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts‎

Lines changed: 139 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@ import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
1515
import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js';
1616
import * as crypto from 'crypto';
1717

18-
vi.mock('../../utils/process.js', () => ({
19-
listAgentProcesses: vi.fn(),
20-
enrichProcesses: vi.fn(),
21-
}));
18+
vi.mock('../../utils/process.js', async (importOriginal) => {
19+
const actual = await importOriginal() as typeof import('../../utils/process.js');
20+
return {
21+
...actual,
22+
listAgentProcesses: vi.fn(),
23+
enrichProcesses: vi.fn(),
24+
};
25+
});
2226

2327
vi.mock('../../utils/matching.js', () => ({
2428
matchProcessesToSessions: vi.fn(),
@@ -158,6 +162,77 @@ describe('GeminiCliAdapter', () => {
158162
});
159163
});
160164

165+
it('should suppress Gemini wrapper process-only agents before a session file exists', async () => {
166+
const wrapperProc: ProcessInfo = {
167+
pid: 20452,
168+
ppid: 17530,
169+
command: '/opt/homebrew/opt/node/bin/node /opt/homebrew/bin/gemini',
170+
cwd: '/repo',
171+
tty: 'ttys007',
172+
startTime: new Date('2026-06-13T08:25:21Z'),
173+
};
174+
const childProc: ProcessInfo = {
175+
pid: 21373,
176+
ppid: 20452,
177+
command: '/opt/homebrew/Cellar/node/26.0.0/bin/node --max-old-space-size=8192 /opt/homebrew/bin/gemini',
178+
cwd: '/repo',
179+
tty: 'ttys007',
180+
startTime: new Date('2026-06-13T08:25:26Z'),
181+
};
182+
mockedListAgentProcesses.mockReturnValue([wrapperProc, childProc]);
183+
184+
const agents = await adapter.detectAgents();
185+
186+
expect(agents).toHaveLength(1);
187+
expect(agents[0]).toMatchObject({
188+
pid: 21373,
189+
sessionId: 'pid-21373',
190+
summary: 'Gemini CLI process running',
191+
});
192+
});
193+
194+
it('should carry the managed wrapper name to a process-only child before a session file exists', async () => {
195+
const regPath = path.join(tmpHome, 'agents.json');
196+
const registry = new AgentRegistry(regPath);
197+
const namedAdapter = new GeminiCliAdapter(registry);
198+
const wrapperProc: ProcessInfo = {
199+
pid: 35792,
200+
ppid: 33068,
201+
command: '/opt/homebrew/opt/node/bin/node /opt/homebrew/bin/gemini',
202+
cwd: '/repo',
203+
tty: 'ttys002',
204+
startTime: new Date('2026-06-13T19:15:16Z'),
205+
};
206+
const childProc: ProcessInfo = {
207+
pid: 36514,
208+
ppid: 35792,
209+
command: '/opt/homebrew/Cellar/node/26.0.0/bin/node --max-old-space-size=8192 /opt/homebrew/bin/gemini',
210+
cwd: '/repo',
211+
tty: 'ttys002',
212+
startTime: new Date('2026-06-13T19:15:18Z'),
213+
};
214+
registry.register({
215+
name: 'cli-mqcqj469',
216+
type: 'gemini_cli',
217+
pid: wrapperProc.pid,
218+
tmuxSession: 'cli-mqcqj469',
219+
cwd: wrapperProc.cwd,
220+
startedAt: '2026-06-13T19:15:16.211Z',
221+
sessionId: `pid-${wrapperProc.pid}`,
222+
sessionFilePath: '',
223+
});
224+
mockedListAgentProcesses.mockReturnValue([wrapperProc, childProc]);
225+
226+
const agents = await namedAdapter.detectAgents();
227+
228+
expect(agents).toHaveLength(1);
229+
expect(agents[0]).toMatchObject({
230+
name: 'cli-mqcqj469',
231+
pid: childProc.pid,
232+
sessionId: `pid-${childProc.pid}`,
233+
});
234+
});
235+
161236
it('should map a process to its matching session file via projectHash', async () => {
162237
const cwd = '/repo/project-a';
163238
const projectHash = hashProjectRoot(cwd);
@@ -433,6 +508,66 @@ describe('GeminiCliAdapter', () => {
433508

434509
expect(agents[0].sessionId).toBe('pid-100');
435510
});
511+
512+
it('carries the managed wrapper name to the detected child process', async () => {
513+
const wrapperProc: ProcessInfo = {
514+
pid: 20339,
515+
ppid: 17570,
516+
command: '/opt/homebrew/opt/node/bin/node /opt/homebrew/bin/gemini',
517+
cwd: '/repo-a',
518+
tty: 'ttys002',
519+
startTime: new Date('2026-06-13T19:00:53Z'),
520+
};
521+
const childProc: ProcessInfo = {
522+
pid: 21038,
523+
ppid: 20339,
524+
command: '/opt/homebrew/Cellar/node/26.0.0/bin/node --max-old-space-size=8192 /opt/homebrew/bin/gemini',
525+
cwd: '/repo-a',
526+
tty: 'ttys002',
527+
startTime: new Date('2026-06-13T19:00:57Z'),
528+
};
529+
const now = new Date().toISOString();
530+
sessionFilePath = writeSession(tmpHome, 'cli-2', 'session-2026-06-13T19-00-s-cached', {
531+
sessionId: 's-cached',
532+
projectHash: hashProjectRoot('/repo-a'),
533+
startTime: now,
534+
lastUpdated: now,
535+
directories: ['/repo-a'],
536+
messages: [
537+
{ id: 'm1', timestamp: now, type: 'user', content: 'Hello from child process' },
538+
],
539+
});
540+
registerEntry({
541+
name: 'cli-mqcq0mg5',
542+
pid: wrapperProc.pid,
543+
tmuxSession: 'cli-mqcq0mg5',
544+
sessionId: 's-cached',
545+
sessionFilePath,
546+
});
547+
mockedListAgentProcesses.mockReturnValue([wrapperProc, childProc]);
548+
mockedMatchProcessesToSessions.mockReturnValue([
549+
{
550+
process: childProc,
551+
session: {
552+
sessionId: 's-cached',
553+
filePath: sessionFilePath,
554+
projectDir: path.dirname(sessionFilePath),
555+
birthtimeMs: Date.now(),
556+
resolvedCwd: '/repo-a',
557+
},
558+
deltaMs: 0,
559+
},
560+
]);
561+
562+
const agents = await cachedAdapter.detectAgents();
563+
564+
expect(agents).toHaveLength(1);
565+
expect(agents[0]).toMatchObject({
566+
name: 'cli-mqcq0mg5',
567+
pid: childProc.pid,
568+
sessionId: 's-cached',
569+
});
570+
});
436571
});
437572

438573
describe('discoverSessions', () => {

‎packages/agent-manager/src/__tests__/utils/process.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
batchGetProcessCwds,
1111
batchGetProcessStartTimes,
1212
enrichProcesses,
13+
findWrapperProcess,
14+
findWrapperProcessPids,
1315
} from '../../utils/process.js';
1416

1517
vi.mock('child_process', () => ({
@@ -207,3 +209,19 @@ describe('enrichProcesses', () => {
207209
expect(enriched[0].startTime).toBeUndefined();
208210
});
209211
});
212+
213+
describe('wrapper process detection', () => {
214+
it('finds the parent wrapper process for a child in the same terminal and cwd', () => {
215+
const wrapper = { pid: 100, ppid: 1, command: 'node /bin/gemini', cwd: '/repo', tty: 'ttys001' };
216+
const child = { pid: 200, ppid: 100, command: 'node --max-old-space-size=8192 /bin/gemini', cwd: '/repo', tty: 'ttys001' };
217+
218+
expect(findWrapperProcess([wrapper, child], child)).toBe(wrapper);
219+
expect(findWrapperProcessPids([wrapper, child])).toEqual(new Set([100]));
220+
});
221+
222+
it('does not mark a matched child process as its own wrapper', () => {
223+
const child = { pid: 200, ppid: 100, command: 'node --max-old-space-size=8192 /bin/gemini', cwd: '/repo', tty: 'ttys001' };
224+
225+
expect(findWrapperProcessPids([child], [child])).toEqual(new Set());
226+
});
227+
});

0 commit comments

Comments
 (0)