Skip to content

Commit 223737b

Browse files
author
StackMemory Bot (CLI)
committed
feat(conductor): add agent status files and stdout log tee
- Agent status files written to ~/.stackmemory/conductor/agents/<issue-id>/status.json with phase, tool calls, files modified, and token estimates - Stdout tee: agent output piped to both internal handler and output.log - Phase inference from JSON-RPC messages (reading/planning/implementing/testing/committing) - `stackmemory conductor status` subcommand renders table of all agent statuses - `stackmemory conductor logs <id> --follow` tails agent output log - 11 tests covering status file I/O, formatElapsed, and subcommand behavior
1 parent 8647fea commit 223737b

3 files changed

Lines changed: 583 additions & 6 deletions

File tree

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2+
import { Command } from 'commander';
3+
import {
4+
mkdtempSync,
5+
rmSync,
6+
mkdirSync,
7+
writeFileSync,
8+
readFileSync,
9+
existsSync,
10+
} from 'fs';
11+
import { join } from 'path';
12+
import { tmpdir, homedir } from 'os';
13+
import { formatElapsed } from '../orchestrate.js';
14+
import { getAgentStatusDir, type AgentStatusFile } from '../orchestrator.js';
15+
16+
describe('conductor observability', () => {
17+
let tempDir: string;
18+
let origHome: string | undefined;
19+
20+
beforeEach(() => {
21+
tempDir = mkdtempSync(join(tmpdir(), 'sm-conductor-obs-'));
22+
// Override HOME so agent status dirs go to our temp
23+
origHome = process.env.HOME;
24+
process.env.HOME = tempDir;
25+
});
26+
27+
afterEach(() => {
28+
process.env.HOME = origHome;
29+
rmSync(tempDir, { recursive: true, force: true });
30+
});
31+
32+
describe('getAgentStatusDir', () => {
33+
it('returns path under ~/.stackmemory/conductor/agents/<id>', () => {
34+
const dir = getAgentStatusDir('STA-499');
35+
expect(dir).toBe(
36+
join(tempDir, '.stackmemory', 'conductor', 'agents', 'STA-499')
37+
);
38+
});
39+
});
40+
41+
describe('AgentStatusFile format', () => {
42+
it('can be written and read as valid JSON', () => {
43+
const dir = join(
44+
tempDir,
45+
'.stackmemory',
46+
'conductor',
47+
'agents',
48+
'STA-499'
49+
);
50+
mkdirSync(dir, { recursive: true });
51+
52+
const status: AgentStatusFile = {
53+
issue: 'STA-499',
54+
pid: 12345,
55+
started: '2026-03-07T20:44:15Z',
56+
lastUpdate: '2026-03-07T20:50:30Z',
57+
phase: 'implementing',
58+
filesModified: 3,
59+
toolCalls: 47,
60+
tokensUsed: 32000,
61+
};
62+
63+
writeFileSync(join(dir, 'status.json'), JSON.stringify(status, null, 2));
64+
65+
const read = JSON.parse(
66+
readFileSync(join(dir, 'status.json'), 'utf-8')
67+
) as AgentStatusFile;
68+
69+
expect(read.issue).toBe('STA-499');
70+
expect(read.phase).toBe('implementing');
71+
expect(read.toolCalls).toBe(47);
72+
expect(read.filesModified).toBe(3);
73+
expect(read.tokensUsed).toBe(32000);
74+
expect(read.pid).toBe(12345);
75+
});
76+
});
77+
78+
describe('formatElapsed', () => {
79+
it('formats seconds', () => {
80+
expect(formatElapsed(5000)).toBe('5s ago');
81+
expect(formatElapsed(30000)).toBe('30s ago');
82+
});
83+
84+
it('formats minutes', () => {
85+
expect(formatElapsed(120000)).toBe('2m ago');
86+
expect(formatElapsed(300000)).toBe('5m ago');
87+
});
88+
89+
it('formats hours', () => {
90+
expect(formatElapsed(3600000)).toBe('1h ago');
91+
expect(formatElapsed(7200000)).toBe('2h ago');
92+
});
93+
94+
it('formats days', () => {
95+
expect(formatElapsed(86400000)).toBe('1d ago');
96+
});
97+
});
98+
99+
describe('conductor status subcommand', () => {
100+
let consoleSpy: ReturnType<typeof vi.spyOn>;
101+
102+
beforeEach(() => {
103+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
104+
});
105+
106+
afterEach(() => {
107+
consoleSpy.mockRestore();
108+
});
109+
110+
it('shows no-data message when no status files exist', async () => {
111+
// Import dynamically to pick up HOME override
112+
const { createConductorCommands } = await import('../orchestrate.js');
113+
114+
const parent = new Command();
115+
parent.addCommand(createConductorCommands());
116+
await parent.parseAsync(['node', 'stackmemory', 'conductor', 'status']);
117+
118+
expect(consoleSpy).toHaveBeenCalledWith('No agent status files found');
119+
});
120+
121+
it('renders table when status files exist', async () => {
122+
// Seed status files
123+
const agentsDir = join(tempDir, '.stackmemory', 'conductor', 'agents');
124+
125+
const dir1 = join(agentsDir, 'STA-492');
126+
mkdirSync(dir1, { recursive: true });
127+
const now = new Date();
128+
writeFileSync(
129+
join(dir1, 'status.json'),
130+
JSON.stringify({
131+
issue: 'STA-492',
132+
pid: 1000,
133+
started: new Date(now.getTime() - 600000).toISOString(),
134+
lastUpdate: new Date(now.getTime() - 120000).toISOString(),
135+
phase: 'implementing',
136+
filesModified: 3,
137+
toolCalls: 47,
138+
tokensUsed: 32000,
139+
})
140+
);
141+
142+
const dir2 = join(agentsDir, 'STA-485');
143+
mkdirSync(dir2, { recursive: true });
144+
writeFileSync(
145+
join(dir2, 'status.json'),
146+
JSON.stringify({
147+
issue: 'STA-485',
148+
pid: 2000,
149+
started: new Date(now.getTime() - 300000).toISOString(),
150+
lastUpdate: new Date(now.getTime() - 30000).toISOString(),
151+
phase: 'testing',
152+
filesModified: 1,
153+
toolCalls: 12,
154+
tokensUsed: 8000,
155+
})
156+
);
157+
158+
const { createConductorCommands } = await import('../orchestrate.js');
159+
160+
const parent = new Command();
161+
parent.addCommand(createConductorCommands());
162+
await parent.parseAsync(['node', 'stackmemory', 'conductor', 'status']);
163+
164+
// Should render header + 2 rows
165+
expect(consoleSpy).toHaveBeenCalledTimes(3);
166+
167+
// Header
168+
const header = consoleSpy.mock.calls[0][0] as string;
169+
expect(header).toContain('Issue');
170+
expect(header).toContain('Phase');
171+
expect(header).toContain('Tools');
172+
173+
// Check both issues appear (order: STA-485 first since more recent lastUpdate)
174+
const allOutput = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
175+
expect(allOutput).toContain('STA-492');
176+
expect(allOutput).toContain('STA-485');
177+
expect(allOutput).toContain('implementing');
178+
expect(allOutput).toContain('testing');
179+
});
180+
});
181+
182+
describe('conductor logs subcommand', () => {
183+
let consoleErrSpy: ReturnType<typeof vi.spyOn>;
184+
185+
beforeEach(() => {
186+
consoleErrSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
187+
});
188+
189+
afterEach(() => {
190+
consoleErrSpy.mockRestore();
191+
});
192+
193+
it('shows error when no log file exists', async () => {
194+
const { createConductorCommands } = await import('../orchestrate.js');
195+
196+
const parent = new Command();
197+
parent.addCommand(createConductorCommands());
198+
199+
await parent.parseAsync([
200+
'node',
201+
'stackmemory',
202+
'conductor',
203+
'logs',
204+
'STA-999',
205+
]);
206+
207+
expect(consoleErrSpy).toHaveBeenCalledWith(
208+
expect.stringContaining('No log file found for STA-999')
209+
);
210+
});
211+
212+
it('tails an existing log file', async () => {
213+
// Create a log file to tail
214+
const dir = join(
215+
tempDir,
216+
'.stackmemory',
217+
'conductor',
218+
'agents',
219+
'STA-500'
220+
);
221+
mkdirSync(dir, { recursive: true });
222+
writeFileSync(join(dir, 'output.log'), 'line1\nline2\nline3\n');
223+
224+
const { createConductorCommands } = await import('../orchestrate.js');
225+
226+
const parent = new Command();
227+
parent.addCommand(createConductorCommands());
228+
229+
// Should not throw — tail the file (non-follow mode)
230+
await parent.parseAsync([
231+
'node',
232+
'stackmemory',
233+
'conductor',
234+
'logs',
235+
'STA-500',
236+
'-n',
237+
'10',
238+
]);
239+
240+
// If we get here without error, tail ran successfully
241+
expect(existsSync(join(dir, 'output.log'))).toBe(true);
242+
});
243+
});
244+
245+
describe('output.log file creation', () => {
246+
it('log file path is under agent status dir', () => {
247+
const dir = getAgentStatusDir('STA-500');
248+
const logPath = join(dir, 'output.log');
249+
expect(logPath).toContain('STA-500');
250+
expect(logPath).toContain('output.log');
251+
});
252+
});
253+
});

src/cli/commands/orchestrate.ts

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,20 @@
1010
*/
1111

1212
import { Command } from 'commander';
13-
import { execSync } from 'child_process';
14-
import { existsSync, mkdirSync, writeFileSync } from 'fs';
13+
import { execSync, spawn as cpSpawn } from 'child_process';
14+
import {
15+
existsSync,
16+
mkdirSync,
17+
writeFileSync,
18+
readFileSync,
19+
readdirSync,
20+
} from 'fs';
1521
import { join } from 'path';
1622
import { homedir, tmpdir } from 'os';
1723
import Database from 'better-sqlite3';
1824
import { logger } from '../../core/monitoring/logger.js';
1925
import { Conductor } from './orchestrator.js';
26+
import { getAgentStatusDir, type AgentStatusFile } from './orchestrator.js';
2027

2128
/** Global store for cross-workspace context */
2229
function getGlobalStorePath(): string {
@@ -58,6 +65,18 @@ function getGlobalDb(): Database.Database {
5865
return db;
5966
}
6067

68+
/** Format elapsed time in human-readable form (e.g., "2m ago", "30s ago") */
69+
export function formatElapsed(ms: number): string {
70+
const seconds = Math.floor(ms / 1000);
71+
if (seconds < 60) return `${seconds}s ago`;
72+
const minutes = Math.floor(seconds / 60);
73+
if (minutes < 60) return `${minutes}m ago`;
74+
const hours = Math.floor(minutes / 60);
75+
if (hours < 24) return `${hours}h ago`;
76+
const days = Math.floor(hours / 24);
77+
return `${days}d ago`;
78+
}
79+
6180
export function createConductorCommands(): Command {
6281
const cmd = new Command('conductor');
6382
cmd.description('Conductor — autonomous agent orchestration via Linear');
@@ -383,6 +402,91 @@ export function createConductorCommands(): Command {
383402
globalDb.close();
384403
});
385404

405+
// --- status ---
406+
cmd
407+
.command('status')
408+
.description('Show running agent status table')
409+
.action(async () => {
410+
const agentsDir = join(homedir(), '.stackmemory', 'conductor', 'agents');
411+
if (!existsSync(agentsDir)) {
412+
console.log('No agent status files found');
413+
return;
414+
}
415+
416+
const entries = readdirSync(agentsDir, { withFileTypes: true });
417+
const statuses: AgentStatusFile[] = [];
418+
419+
for (const entry of entries) {
420+
if (!entry.isDirectory()) continue;
421+
const statusPath = join(agentsDir, entry.name, 'status.json');
422+
if (!existsSync(statusPath)) continue;
423+
try {
424+
const data = JSON.parse(readFileSync(statusPath, 'utf-8'));
425+
statuses.push(data as AgentStatusFile);
426+
} catch {
427+
// skip corrupt files
428+
}
429+
}
430+
431+
if (statuses.length === 0) {
432+
console.log('No agent status files found');
433+
return;
434+
}
435+
436+
// Sort by lastUpdate descending (most recent first)
437+
statuses.sort(
438+
(a, b) =>
439+
new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime()
440+
);
441+
442+
// Render table
443+
const header = `${'Issue'.padEnd(12)}${'Phase'.padEnd(16)}${'Tools'.padStart(7)}${'Files'.padStart(7)}${'Tokens'.padStart(9)} Last Update`;
444+
console.log(header);
445+
446+
for (const s of statuses) {
447+
const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
448+
const lastUpdate = formatElapsed(elapsed);
449+
const line = `${s.issue.padEnd(12)}${s.phase.padEnd(16)}${String(s.toolCalls).padStart(7)}${String(s.filesModified).padStart(7)}${String(s.tokensUsed).padStart(9)} ${lastUpdate}`;
450+
console.log(line);
451+
}
452+
});
453+
454+
// --- logs ---
455+
cmd
456+
.command('logs')
457+
.description('Tail agent output log')
458+
.argument('<issue-id>', 'Issue identifier (e.g., STA-499)')
459+
.option('-f, --follow', 'Follow the log (tail -f)', false)
460+
.option('-n, --lines <n>', 'Number of lines to show', '50')
461+
.action(async (issueId, options) => {
462+
const logPath = join(getAgentStatusDir(issueId), 'output.log');
463+
464+
if (!existsSync(logPath)) {
465+
console.error(`No log file found for ${issueId} at ${logPath}`);
466+
return;
467+
}
468+
469+
const lines = parseInt(options.lines, 10);
470+
const args = options.follow
471+
? ['-f', '-n', String(lines), logPath]
472+
: ['-n', String(lines), logPath];
473+
474+
const tail = cpSpawn('tail', args, { stdio: 'inherit' });
475+
476+
await new Promise<void>((resolve) => {
477+
tail.on('close', () => {
478+
resolve();
479+
});
480+
481+
// Forward signals to tail
482+
const forward = () => {
483+
tail.kill('SIGTERM');
484+
};
485+
process.on('SIGINT', forward);
486+
process.on('SIGTERM', forward);
487+
});
488+
});
489+
386490
// --- start ---
387491
cmd
388492
.command('start')

0 commit comments

Comments
 (0)