Skip to content

Commit cec1707

Browse files
author
StackMemory Bot (CLI)
committed
feat(symphony): add orchestrator daemon with Linear polling and agent dispatch
- symphony start: polls Linear for issues, creates git worktrees, spawns Claude Code agents via claude-app-server.cjs, manages bounded concurrency - Lifecycle hooks: after-create (restore), after-run (capture), before-remove (archive) - State transitions: Todo → In Progress → In Review - Retry with exponential backoff, reconciliation of externally-cancelled issues - fix: timer leak in task-coordinator cleanup() Promise.race - fix: remove --dangerously-skip-permissions from subagent CLI spawn - fix: remove dead activeSubagents map and mockTaskToolExecution
1 parent bdc12d6 commit cec1707

7 files changed

Lines changed: 1301 additions & 179 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@stackmemoryai/stackmemory",
3-
"version": "1.3.1",
3+
"version": "1.3.2",
44
"description": "Project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, Claude/Codex/OpenCode wrappers, Linear sync, automatic hooks, and log analysis.",
55
"engines": {
66
"node": ">=20.0.0",
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
import * as os from 'os';
5+
import {
6+
SymphonyOrchestrator,
7+
type SymphonyConfig,
8+
} from '../symphony-orchestrator.js';
9+
10+
// Mock child_process to prevent real spawns
11+
vi.mock('child_process', () => ({
12+
spawn: vi.fn(() => {
13+
const proc = {
14+
stdin: { write: vi.fn() },
15+
stdout: { on: vi.fn() },
16+
stderr: { on: vi.fn() },
17+
on: vi.fn(),
18+
kill: vi.fn(),
19+
killed: false,
20+
pid: 12345,
21+
};
22+
return proc;
23+
}),
24+
execSync: vi.fn(() => ''),
25+
}));
26+
27+
// Mock Linear client and auth
28+
vi.mock('../../../integrations/linear/client.js', () => ({
29+
LinearClient: vi.fn().mockImplementation(() => ({
30+
getIssues: vi.fn().mockResolvedValue([]),
31+
getWorkflowStates: vi.fn().mockResolvedValue([
32+
{ id: 'state-todo', name: 'Todo', type: 'unstarted', color: '#ccc' },
33+
{ id: 'state-ip', name: 'In Progress', type: 'started', color: '#0f0' },
34+
{ id: 'state-ir', name: 'In Review', type: 'started', color: '#00f' },
35+
{ id: 'state-done', name: 'Done', type: 'completed', color: '#0f0' },
36+
]),
37+
updateIssueState: vi.fn().mockResolvedValue({ success: true }),
38+
})),
39+
}));
40+
41+
vi.mock('../../../integrations/linear/auth.js', () => ({
42+
LinearAuthManager: vi.fn().mockImplementation(() => ({
43+
getValidToken: vi.fn().mockResolvedValue('test-token'),
44+
})),
45+
}));
46+
47+
// Mock logger
48+
vi.mock('../../../core/monitoring/logger.js', () => ({
49+
logger: {
50+
info: vi.fn(),
51+
warn: vi.fn(),
52+
error: vi.fn(),
53+
debug: vi.fn(),
54+
},
55+
}));
56+
57+
function makeMockIssue(overrides: Partial<any> = {}) {
58+
return {
59+
id: overrides.id || 'issue-1',
60+
identifier: overrides.identifier || 'STA-100',
61+
title: overrides.title || 'Test issue',
62+
description: overrides.description || 'Test description',
63+
state: overrides.state || {
64+
id: 'state-todo',
65+
name: 'Todo',
66+
type: 'unstarted',
67+
},
68+
priority: overrides.priority ?? 3,
69+
assignee: overrides.assignee || null,
70+
estimate: overrides.estimate || null,
71+
labels: overrides.labels || [],
72+
createdAt: '2026-01-01T00:00:00Z',
73+
updatedAt: '2026-01-01T00:00:00Z',
74+
url: 'https://linear.app/test/issue/STA-100',
75+
...overrides,
76+
};
77+
}
78+
79+
describe('SymphonyOrchestrator', () => {
80+
let tmpDir: string;
81+
82+
beforeEach(() => {
83+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'symphony-orch-test-'));
84+
vi.clearAllMocks();
85+
});
86+
87+
afterEach(() => {
88+
fs.rmSync(tmpDir, { recursive: true, force: true });
89+
});
90+
91+
function makeConfig(
92+
overrides: Partial<SymphonyConfig> = {}
93+
): Partial<SymphonyConfig> {
94+
// Create the app-server file so the orchestrator doesn't throw
95+
const appServerPath = path.join(tmpDir, 'claude-app-server.cjs');
96+
fs.writeFileSync(appServerPath, '// mock');
97+
98+
return {
99+
workspaceRoot: path.join(tmpDir, 'workspaces'),
100+
repoRoot: tmpDir,
101+
appServerPath,
102+
pollIntervalMs: 100,
103+
maxConcurrent: 2,
104+
teamId: 'team-1',
105+
...overrides,
106+
};
107+
}
108+
109+
describe('constructor', () => {
110+
it('should create with default config', () => {
111+
const orch = new SymphonyOrchestrator();
112+
const stats = orch.getStats();
113+
expect(stats.running).toBe(0);
114+
expect(stats.completed).toBe(0);
115+
expect(stats.failed).toBe(0);
116+
});
117+
118+
it('should merge provided config over defaults', () => {
119+
const orch = new SymphonyOrchestrator({
120+
maxConcurrent: 5,
121+
pollIntervalMs: 60000,
122+
});
123+
const stats = orch.getStats();
124+
expect(stats.running).toBe(0);
125+
});
126+
});
127+
128+
describe('getStats', () => {
129+
it('should return initial stats', () => {
130+
const orch = new SymphonyOrchestrator(makeConfig());
131+
const stats = orch.getStats();
132+
133+
expect(stats.running).toBe(0);
134+
expect(stats.completed).toBe(0);
135+
expect(stats.failed).toBe(0);
136+
expect(stats.totalAttempts).toBe(0);
137+
expect(stats.issues).toEqual([]);
138+
});
139+
});
140+
141+
describe('start and stop', () => {
142+
it('should start and stop cleanly with no issues', async () => {
143+
const orch = new SymphonyOrchestrator(makeConfig());
144+
145+
// Start orchestrator (will poll once then schedule)
146+
const startPromise = orch.start();
147+
148+
// Let it run one poll cycle
149+
await new Promise((r) => setTimeout(r, 200));
150+
151+
// Stop
152+
await orch.stop();
153+
154+
// Verify stats
155+
const stats = orch.getStats();
156+
expect(stats.running).toBe(0);
157+
expect(stats.uptime).toBeGreaterThan(0);
158+
});
159+
160+
it('should not double-stop', async () => {
161+
const orch = new SymphonyOrchestrator(makeConfig());
162+
const startPromise = orch.start();
163+
await new Promise((r) => setTimeout(r, 100));
164+
165+
await orch.stop();
166+
await orch.stop(); // second stop should be a no-op
167+
168+
const stats = orch.getStats();
169+
expect(stats.running).toBe(0);
170+
});
171+
172+
it('should throw if app-server not found', async () => {
173+
const orch = new SymphonyOrchestrator({
174+
...makeConfig(),
175+
appServerPath: '/nonexistent/path/claude-app-server.cjs',
176+
repoRoot: '/nonexistent',
177+
});
178+
179+
await expect(orch.start()).rejects.toThrow(
180+
'claude-app-server.cjs not found'
181+
);
182+
});
183+
});
184+
185+
describe('config defaults', () => {
186+
it('should have sensible defaults', () => {
187+
const orch = new SymphonyOrchestrator();
188+
// Just verify it constructs without error
189+
expect(orch).toBeDefined();
190+
});
191+
192+
it('should accept partial config', () => {
193+
const orch = new SymphonyOrchestrator({
194+
maxConcurrent: 10,
195+
activeStates: ['Ready', 'Todo'],
196+
});
197+
expect(orch).toBeDefined();
198+
});
199+
});
200+
201+
describe('prompt building', () => {
202+
it('should include issue details in prompt', () => {
203+
const orch = new SymphonyOrchestrator(makeConfig());
204+
205+
// Access private method via any cast for testing
206+
const prompt = (orch as any).buildPrompt(
207+
makeMockIssue({
208+
identifier: 'STA-200',
209+
title: 'Add auth flow',
210+
description: 'Implement OAuth2',
211+
labels: [{ id: 'l1', name: 'security' }],
212+
priority: 2,
213+
}),
214+
1
215+
);
216+
217+
expect(prompt).toContain('STA-200');
218+
expect(prompt).toContain('Add auth flow');
219+
expect(prompt).toContain('Implement OAuth2');
220+
expect(prompt).toContain('security');
221+
expect(prompt).toContain('High');
222+
});
223+
224+
it('should include retry context on attempt > 1', () => {
225+
const orch = new SymphonyOrchestrator(makeConfig());
226+
227+
const prompt = (orch as any).buildPrompt(makeMockIssue(), 3);
228+
229+
expect(prompt).toContain('attempt 3');
230+
expect(prompt).toContain('symphony-context.md');
231+
});
232+
233+
it('should not include retry context on first attempt', () => {
234+
const orch = new SymphonyOrchestrator(makeConfig());
235+
236+
const prompt = (orch as any).buildPrompt(makeMockIssue(), 1);
237+
238+
expect(prompt).not.toContain('attempt 1');
239+
expect(prompt).not.toContain('symphony-context.md');
240+
});
241+
});
242+
243+
describe('sanitizeIdentifier', () => {
244+
it('should replace non-alphanumeric chars', () => {
245+
const orch = new SymphonyOrchestrator(makeConfig());
246+
247+
expect((orch as any).sanitizeIdentifier('STA-100')).toBe('STA-100');
248+
expect((orch as any).sanitizeIdentifier('STA/100')).toBe('STA_100');
249+
expect((orch as any).sanitizeIdentifier('a b c')).toBe('a_b_c');
250+
expect((orch as any).sanitizeIdentifier('test.issue')).toBe('test.issue');
251+
});
252+
});
253+
254+
describe('state transitions', () => {
255+
it('should populate state cache when client is available', async () => {
256+
const orch = new SymphonyOrchestrator(makeConfig());
257+
258+
// Simulate what start() does: create client and cache states
259+
(orch as any).client = {
260+
getIssues: vi.fn().mockResolvedValue([]),
261+
getWorkflowStates: vi.fn().mockResolvedValue([
262+
{ id: 'state-todo', name: 'Todo', type: 'unstarted', color: '#ccc' },
263+
{
264+
id: 'state-ip',
265+
name: 'In Progress',
266+
type: 'started',
267+
color: '#0f0',
268+
},
269+
]),
270+
updateIssueState: vi.fn().mockResolvedValue({ success: true }),
271+
};
272+
273+
await (orch as any).cacheWorkflowStates();
274+
275+
const cache = (orch as any).stateCache as Map<string, any>;
276+
expect(cache.size).toBe(2);
277+
expect(cache.has('todo')).toBe(true);
278+
expect(cache.has('in progress')).toBe(true);
279+
expect(cache.get('todo').id).toBe('state-todo');
280+
});
281+
});
282+
283+
describe('hook execution', () => {
284+
it('should skip hooks that do not exist', async () => {
285+
const orch = new SymphonyOrchestrator(makeConfig());
286+
287+
// Should not throw — hook file doesn't exist
288+
await (orch as any).runHook('nonexistent-hook', tmpDir, makeMockIssue());
289+
});
290+
});
291+
292+
describe('workspace management', () => {
293+
it('should reuse existing workspace', async () => {
294+
const config = makeConfig();
295+
const orch = new SymphonyOrchestrator(config);
296+
297+
const wsPath = path.join(config.workspaceRoot!, 'STA-100');
298+
fs.mkdirSync(wsPath, { recursive: true });
299+
300+
const result = await (orch as any).createWorkspace(makeMockIssue());
301+
expect(result).toBe(wsPath);
302+
});
303+
});
304+
});

0 commit comments

Comments
 (0)