Skip to content

Commit f4e189d

Browse files
committed
fix(agent-manager): prefer PID-file live status for Claude Code agents
1 parent a98e7ac commit f4e189d

2 files changed

Lines changed: 299 additions & 37 deletions

File tree

packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,194 @@ describe('ClaudeCodeAdapter', () => {
408408
fs.rmSync(tmpDir, { recursive: true, force: true });
409409
});
410410

411+
it('should prefer PID-file live status over JSONL-derived status and surface waitingFor in summary', async () => {
412+
const startTime = new Date();
413+
const processes: ProcessInfo[] = [
414+
{ pid: 55050, command: 'claude', cwd: '/project/wait', tty: 'ttys001', startTime },
415+
];
416+
mockedListAgentProcesses.mockReturnValue(processes);
417+
mockedEnrichProcesses.mockReturnValue(processes);
418+
419+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-wait-'));
420+
const sessionsDir = path.join(tmpDir, 'sessions');
421+
const projectsDir = path.join(tmpDir, 'projects');
422+
const projDir = path.join(projectsDir, '-project-wait');
423+
fs.mkdirSync(sessionsDir, { recursive: true });
424+
fs.mkdirSync(projDir, { recursive: true });
425+
426+
const sessionId = 'wait-session';
427+
const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
428+
// JSONL trails with permission-mode → parser would resolve to UNKNOWN.
429+
// PID file's live status must win.
430+
fs.writeFileSync(jsonlPath, [
431+
JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/wait', message: { content: '/reddit-commenter' } }),
432+
JSON.stringify({ type: 'permission-mode', timestamp: new Date().toISOString(), permissionMode: 'default' }),
433+
].join('\n'));
434+
435+
fs.writeFileSync(
436+
path.join(sessionsDir, '55050.json'),
437+
JSON.stringify({
438+
pid: 55050, sessionId, cwd: '/project/wait',
439+
startedAt: startTime.getTime(),
440+
kind: 'interactive', entrypoint: 'cli',
441+
status: 'waiting', waitingFor: 'approve Read',
442+
}),
443+
);
444+
445+
(adapter as any).sessionsDir = sessionsDir;
446+
(adapter as any).projectsDir = projectsDir;
447+
448+
const agents = await adapter.detectAgents();
449+
450+
expect(agents).toHaveLength(1);
451+
expect(agents[0].status).toBe(AgentStatus.WAITING);
452+
expect(agents[0].summary).toContain('/reddit-commenter');
453+
expect(agents[0].summary).toContain('waiting for approve Read');
454+
455+
fs.rmSync(tmpDir, { recursive: true, force: true });
456+
});
457+
458+
it('should resolve PID-file status: "idle" to AgentStatus.IDLE even when JSONL would say UNKNOWN', async () => {
459+
const startTime = new Date();
460+
const processes: ProcessInfo[] = [
461+
{ pid: 55051, command: 'claude', cwd: '/project/idle', tty: 'ttys001', startTime },
462+
];
463+
mockedListAgentProcesses.mockReturnValue(processes);
464+
mockedEnrichProcesses.mockReturnValue(processes);
465+
466+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-idle-'));
467+
const sessionsDir = path.join(tmpDir, 'sessions');
468+
const projectsDir = path.join(tmpDir, 'projects');
469+
const projDir = path.join(projectsDir, '-project-idle');
470+
fs.mkdirSync(sessionsDir, { recursive: true });
471+
fs.mkdirSync(projDir, { recursive: true });
472+
473+
const sessionId = 'idle-session';
474+
const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
475+
fs.writeFileSync(jsonlPath, [
476+
JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/idle', message: { content: 'hello' } }),
477+
JSON.stringify({ type: 'permission-mode', timestamp: new Date().toISOString(), permissionMode: 'default' }),
478+
].join('\n'));
479+
480+
fs.writeFileSync(
481+
path.join(sessionsDir, '55051.json'),
482+
JSON.stringify({
483+
pid: 55051, sessionId, cwd: '/project/idle',
484+
startedAt: startTime.getTime(),
485+
kind: 'interactive', entrypoint: 'cli',
486+
status: 'idle',
487+
}),
488+
);
489+
490+
(adapter as any).sessionsDir = sessionsDir;
491+
(adapter as any).projectsDir = projectsDir;
492+
493+
const agents = await adapter.detectAgents();
494+
495+
expect(agents).toHaveLength(1);
496+
expect(agents[0].status).toBe(AgentStatus.IDLE);
497+
// No waitingFor in PID file → summary is just the last user message
498+
expect(agents[0].summary).toBe('hello');
499+
500+
fs.rmSync(tmpDir, { recursive: true, force: true });
501+
});
502+
503+
it('should fall back to JSONL-derived status when PID-file status is missing or unrecognized', async () => {
504+
const startTime = new Date();
505+
const processes: ProcessInfo[] = [
506+
{ pid: 55052, command: 'claude', cwd: '/project/legacy', tty: 'ttys001', startTime },
507+
];
508+
mockedListAgentProcesses.mockReturnValue(processes);
509+
mockedEnrichProcesses.mockReturnValue(processes);
510+
511+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-nostat-'));
512+
const sessionsDir = path.join(tmpDir, 'sessions');
513+
const projectsDir = path.join(tmpDir, 'projects');
514+
const projDir = path.join(projectsDir, '-project-legacy');
515+
fs.mkdirSync(sessionsDir, { recursive: true });
516+
fs.mkdirSync(projDir, { recursive: true });
517+
518+
const sessionId = 'legacy-session';
519+
const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
520+
// Last entry is assistant → JSONL parser yields WAITING.
521+
fs.writeFileSync(jsonlPath, [
522+
JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/legacy', message: { content: 'do the thing' } }),
523+
JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
524+
].join('\n'));
525+
526+
// PID file with unrecognized status string — adapter must ignore it and use parser
527+
fs.writeFileSync(
528+
path.join(sessionsDir, '55052.json'),
529+
JSON.stringify({
530+
pid: 55052, sessionId, cwd: '/project/legacy',
531+
startedAt: startTime.getTime(),
532+
kind: 'interactive', entrypoint: 'cli',
533+
status: 'fantastical-future-state',
534+
}),
535+
);
536+
537+
(adapter as any).sessionsDir = sessionsDir;
538+
(adapter as any).projectsDir = projectsDir;
539+
540+
const agents = await adapter.detectAgents();
541+
542+
expect(agents).toHaveLength(1);
543+
expect(agents[0].status).toBe(AgentStatus.WAITING);
544+
545+
fs.rmSync(tmpDir, { recursive: true, force: true });
546+
});
547+
548+
it('should pick up live status from PID file even when matched via --resume', async () => {
549+
const sessionId = 'aaaaaaaa-1111-2222-3333-444444444444';
550+
const startTime = new Date();
551+
const processes: ProcessInfo[] = [
552+
{
553+
pid: 55053,
554+
command: `claude --resume ${sessionId}`,
555+
cwd: '/project/resume-wait',
556+
tty: 'ttys001',
557+
startTime,
558+
},
559+
];
560+
mockedListAgentProcesses.mockReturnValue(processes);
561+
mockedEnrichProcesses.mockReturnValue(processes);
562+
563+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-resume-wait-'));
564+
const sessionsDir = path.join(tmpDir, 'sessions');
565+
const projectsDir = path.join(tmpDir, 'projects');
566+
const projDir = path.join(projectsDir, '-project-resume-wait');
567+
fs.mkdirSync(sessionsDir, { recursive: true });
568+
fs.mkdirSync(projDir, { recursive: true });
569+
570+
const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
571+
fs.writeFileSync(jsonlPath, [
572+
JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/resume-wait', message: { content: 'resumed work' } }),
573+
JSON.stringify({ type: 'permission-mode', timestamp: new Date().toISOString(), permissionMode: 'default' }),
574+
].join('\n'));
575+
576+
fs.writeFileSync(
577+
path.join(sessionsDir, '55053.json'),
578+
JSON.stringify({
579+
pid: 55053, sessionId, cwd: '/project/resume-wait',
580+
startedAt: startTime.getTime(),
581+
kind: 'interactive', entrypoint: 'cli',
582+
status: 'waiting', waitingFor: 'approve Bash',
583+
}),
584+
);
585+
586+
(adapter as any).sessionsDir = sessionsDir;
587+
(adapter as any).projectsDir = projectsDir;
588+
589+
const agents = await adapter.detectAgents();
590+
591+
expect(agents).toHaveLength(1);
592+
expect(agents[0].status).toBe(AgentStatus.WAITING);
593+
expect(agents[0].summary).toContain('resumed work');
594+
expect(agents[0].summary).toContain('waiting for approve Bash');
595+
596+
fs.rmSync(tmpDir, { recursive: true, force: true });
597+
});
598+
411599
it('should fall back to process-only when direct-matched JSONL becomes unreadable', async () => {
412600
const startTime = new Date();
413601
const processes: ProcessInfo[] = [

0 commit comments

Comments
 (0)