Skip to content

Commit bd40286

Browse files
committed
fix(agent-manager): Copilot wrapper process duplicate agents
1 parent b0c3d45 commit bd40286

5 files changed

Lines changed: 69 additions & 17 deletions

File tree

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,24 @@ describe('CopilotAdapter', () => {
220220
});
221221
});
222222

223+
it('suppresses wrapper process-only agents before the session lock exists', async () => {
224+
const processes: ProcessInfo[] = [
225+
{ pid: 86800, command: 'copilot', cwd: '/repo', tty: 'ttys001', ppid: 84174 },
226+
{ pid: 86810, command: '/custom/install/copilot', cwd: '/repo', tty: 'ttys001', ppid: 86800 },
227+
];
228+
mockedListAgentProcesses.mockReturnValue(processes);
229+
mockedEnrichProcesses.mockReturnValue(processes);
230+
231+
const agents = await adapter.detectAgents();
232+
233+
expect(agents).toHaveLength(1);
234+
expect(agents[0]).toMatchObject({
235+
pid: 86810,
236+
sessionId: 'pid-86810',
237+
summary: 'Copilot process running',
238+
});
239+
});
240+
223241
it('does not add duplicate process-only agent for wrapper process in the same terminal', async () => {
224242
const processes: ProcessInfo[] = [
225243
{ pid: 14095, command: 'copilot', cwd: '/repo', tty: 'ttys001' },

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,26 +23,28 @@ describe('listAgentProcesses', () => {
2323
mockedExecFileSync.mockReset();
2424
});
2525

26-
it('should parse ps aux | grep output and post-filter by executable name', () => {
26+
it('should parse ps output and post-filter by executable name', () => {
2727
mockedExecFileSync.mockReturnValue(
28-
'user 78070 1.0 0.5 485636016 245952 s018 S+ 11:18PM 1:55.14 claude\n' +
29-
'user 55106 0.1 0.4 485620368 72496 s015 S+ 9Mar26 8:06.36 claude\n',
28+
'78070 1 s018 claude\n' +
29+
'55106 55100 s015 claude\n',
3030
);
3131

3232
const processes = listAgentProcesses('claude');
3333
expect(processes).toHaveLength(2);
3434
expect(processes[0].pid).toBe(78070);
35+
expect(processes[0].ppid).toBe(1);
3536
expect(processes[0].command).toBe('claude');
3637
expect(processes[0].tty).toBe('s018');
3738
expect(processes[0].cwd).toBe(''); // not populated yet
3839
expect(processes[1].pid).toBe(55106);
40+
expect(processes[1].ppid).toBe(55100);
3941
});
4042

4143
it('should filter out non-matching executables', () => {
4244
mockedExecFileSync.mockReturnValue(
43-
'user 100 0.0 0.0 0 0 s001 S 1:00PM 0:00 claude\n' +
44-
'user 200 0.0 0.0 0 0 s002 S 1:00PM 0:00 claude-helper --pid 100\n' +
45-
'user 300 0.0 0.0 0 0 s003 S 1:00PM 0:00 /usr/bin/claude\n',
45+
'100 1 s001 claude\n' +
46+
'200 1 s002 claude-helper --pid 100\n' +
47+
'300 1 s003 /usr/bin/claude\n',
4648
);
4749

4850
const processes = listAgentProcesses('claude');
@@ -75,7 +77,11 @@ describe('listAgentProcesses', () => {
7577
it('should accept valid patterns with dashes and underscores', () => {
7678
mockedExecFileSync.mockReturnValue('');
7779
listAgentProcesses('claude-code');
78-
expect(mockedExecFileSync).toHaveBeenCalled();
80+
expect(mockedExecFileSync).toHaveBeenCalledWith(
81+
'ps',
82+
['-axo', 'pid=,ppid=,tty=,command='],
83+
{ encoding: 'utf-8' },
84+
);
7985

8086
mockedExecFileSync.mockReset();
8187
mockedExecFileSync.mockReturnValue('');

‎packages/agent-manager/src/adapters/AgentAdapter.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ export interface ProcessInfo {
5959
/** Process ID */
6060
pid: number;
6161

62+
/** Parent process ID, populated by listAgentProcesses when available */
63+
ppid?: number;
64+
6265
/** Process command */
6366
command: string;
6467

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,9 @@ export class CopilotAdapter implements AgentAdapter {
138138
matchedProcesses.push(proc);
139139
}
140140

141+
const wrapperPids = this.findWrapperProcessPids(processes, matchedProcesses);
141142
for (const proc of processes) {
142-
if (!matchedPids.has(proc.pid) && !this.isDuplicateProcess(proc, matchedProcesses)) {
143+
if (!matchedPids.has(proc.pid) && !wrapperPids.has(proc.pid)) {
143144
agents.push(this.mapProcessOnlyAgent(proc));
144145
}
145146
}
@@ -158,6 +159,28 @@ export class CopilotAdapter implements AgentAdapter {
158159
});
159160
}
160161

162+
private findWrapperProcessPids(processes: ProcessInfo[], matchedProcesses: ProcessInfo[]): Set<number> {
163+
const wrappers = new Set<number>();
164+
165+
for (const proc of processes) {
166+
for (const child of processes) {
167+
if (proc.pid === child.pid) continue;
168+
if (child.ppid !== proc.pid) continue;
169+
if (!this.isDuplicateProcess(proc, [child])) continue;
170+
171+
wrappers.add(proc.pid);
172+
}
173+
}
174+
175+
for (const proc of processes) {
176+
if (this.isDuplicateProcess(proc, matchedProcesses)) {
177+
wrappers.add(proc.pid);
178+
}
179+
}
180+
181+
return wrappers;
182+
}
183+
161184
getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
162185
const verbose = options?.verbose ?? false;
163186
const content = safeReadFile(sessionFilePath);

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

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ import type { ProcessInfo } from '../adapters/AgentAdapter.js';
1212
/**
1313
* List running processes matching an agent executable name.
1414
*
15-
* Uses `ps aux` then filters in JS for exact executable basename match.
15+
* Uses `ps -axo` then filters in JS for exact executable basename match.
1616
* This avoids shell pipelines and string interpolation.
1717
*
18-
* Returned ProcessInfo has pid, command, tty populated.
18+
* Returned ProcessInfo has pid, ppid, command, tty populated.
1919
* cwd and startTime are NOT populated — call enrichProcesses() to fill them.
2020
*/
2121
export function listAgentProcesses(namePattern: string): ProcessInfo[] {
@@ -25,22 +25,23 @@ export function listAgentProcesses(namePattern: string): ProcessInfo[] {
2525
}
2626

2727
try {
28-
const output = execFileSync('ps', ['aux'], { encoding: 'utf-8' });
28+
const output = execFileSync('ps', ['-axo', 'pid=,ppid=,tty=,command='], { encoding: 'utf-8' });
2929

3030
const lowerPattern = namePattern.toLowerCase();
3131
const processes: ProcessInfo[] = [];
3232

3333
for (const line of output.trim().split('\n')) {
3434
if (!line.trim()) continue;
3535

36-
const parts = line.trim().split(/\s+/);
37-
if (parts.length < 11) continue;
36+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
37+
if (!match) continue;
3838

39-
const pid = parseInt(parts[1], 10);
40-
if (Number.isNaN(pid)) continue;
39+
const pid = parseInt(match[1], 10);
40+
const ppid = parseInt(match[2], 10);
41+
if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;
4142

42-
const tty = parts[6];
43-
const command = parts.slice(10).join(' ');
43+
const tty = match[3];
44+
const command = match[4];
4445

4546
// Check that the executable basename matches exactly
4647
const executable = command.trim().split(/\s+/)[0] || '';
@@ -53,6 +54,7 @@ export function listAgentProcesses(namePattern: string): ProcessInfo[] {
5354

5455
processes.push({
5556
pid,
57+
ppid,
5658
command,
5759
cwd: '',
5860
tty: ttyShort,

0 commit comments

Comments
 (0)