@stackmemoryai/stackmemory 1.3.1 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import { fileURLToPath as __fileURLToPath } from 'url';
|
|
2
|
+
import { dirname as __pathDirname } from 'path';
|
|
3
|
+
const __filename = __fileURLToPath(import.meta.url);
|
|
4
|
+
const __dirname = __pathDirname(__filename);
|
|
5
|
+
import { spawn, execSync } from "child_process";
|
|
6
|
+
import { existsSync, mkdirSync, rmSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { tmpdir } from "os";
|
|
9
|
+
import { logger } from "../../core/monitoring/logger.js";
|
|
10
|
+
import {
|
|
11
|
+
LinearClient
|
|
12
|
+
} from "../../integrations/linear/client.js";
|
|
13
|
+
import { LinearAuthManager } from "../../integrations/linear/auth.js";
|
|
14
|
+
const DEFAULT_CONFIG = {
|
|
15
|
+
activeStates: ["Todo"],
|
|
16
|
+
terminalStates: ["Done", "Cancelled", "Canceled", "Closed"],
|
|
17
|
+
inProgressState: "In Progress",
|
|
18
|
+
inReviewState: "In Review",
|
|
19
|
+
pollIntervalMs: 3e4,
|
|
20
|
+
maxConcurrent: 3,
|
|
21
|
+
workspaceRoot: join(tmpdir(), "symphony_workspaces"),
|
|
22
|
+
repoRoot: process.cwd(),
|
|
23
|
+
baseBranch: "main",
|
|
24
|
+
appServerPath: join(
|
|
25
|
+
__dirname,
|
|
26
|
+
"..",
|
|
27
|
+
"..",
|
|
28
|
+
"..",
|
|
29
|
+
"scripts",
|
|
30
|
+
"symphony",
|
|
31
|
+
"claude-app-server.cjs"
|
|
32
|
+
),
|
|
33
|
+
turnTimeoutMs: 36e5,
|
|
34
|
+
maxRetries: 1,
|
|
35
|
+
hookTimeoutMs: 6e4
|
|
36
|
+
};
|
|
37
|
+
class SymphonyOrchestrator {
|
|
38
|
+
config;
|
|
39
|
+
client = null;
|
|
40
|
+
running = /* @__PURE__ */ new Map();
|
|
41
|
+
claimed = /* @__PURE__ */ new Set();
|
|
42
|
+
completed = /* @__PURE__ */ new Set();
|
|
43
|
+
pollTimer = null;
|
|
44
|
+
startedAt = 0;
|
|
45
|
+
totalAttempts = 0;
|
|
46
|
+
failCount = 0;
|
|
47
|
+
completeCount = 0;
|
|
48
|
+
stopping = false;
|
|
49
|
+
stateCache = /* @__PURE__ */ new Map();
|
|
50
|
+
constructor(config = {}) {
|
|
51
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Start the orchestrator loop.
|
|
55
|
+
* Resolves when stopped via stop() or SIGINT/SIGTERM.
|
|
56
|
+
*/
|
|
57
|
+
async start() {
|
|
58
|
+
this.startedAt = Date.now();
|
|
59
|
+
this.stopping = false;
|
|
60
|
+
if (!existsSync(this.config.appServerPath)) {
|
|
61
|
+
const altPath = join(
|
|
62
|
+
this.config.repoRoot,
|
|
63
|
+
"scripts",
|
|
64
|
+
"symphony",
|
|
65
|
+
"claude-app-server.cjs"
|
|
66
|
+
);
|
|
67
|
+
if (existsSync(altPath)) {
|
|
68
|
+
this.config.appServerPath = altPath;
|
|
69
|
+
} else {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`claude-app-server.cjs not found at ${this.config.appServerPath}`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!existsSync(this.config.workspaceRoot)) {
|
|
76
|
+
mkdirSync(this.config.workspaceRoot, { recursive: true });
|
|
77
|
+
}
|
|
78
|
+
this.client = await this.createLinearClient();
|
|
79
|
+
await this.cacheWorkflowStates();
|
|
80
|
+
logger.info("Symphony orchestrator started", {
|
|
81
|
+
activeStates: this.config.activeStates,
|
|
82
|
+
maxConcurrent: this.config.maxConcurrent,
|
|
83
|
+
pollIntervalMs: this.config.pollIntervalMs,
|
|
84
|
+
workspaceRoot: this.config.workspaceRoot
|
|
85
|
+
});
|
|
86
|
+
console.log(
|
|
87
|
+
`Symphony started \u2014 polling every ${this.config.pollIntervalMs / 1e3}s, max ${this.config.maxConcurrent} concurrent`
|
|
88
|
+
);
|
|
89
|
+
const shutdown = () => this.stop();
|
|
90
|
+
process.on("SIGINT", shutdown);
|
|
91
|
+
process.on("SIGTERM", shutdown);
|
|
92
|
+
try {
|
|
93
|
+
await this.poll();
|
|
94
|
+
} catch (err) {
|
|
95
|
+
logger.error("Initial poll failed", { error: err.message });
|
|
96
|
+
}
|
|
97
|
+
await this.schedulePoll();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Gracefully stop the orchestrator.
|
|
101
|
+
* Waits for running agents to finish (up to 30s), then force-kills.
|
|
102
|
+
*/
|
|
103
|
+
async stop() {
|
|
104
|
+
if (this.stopping) return;
|
|
105
|
+
this.stopping = true;
|
|
106
|
+
console.log("\nSymphony stopping...");
|
|
107
|
+
logger.info("Symphony orchestrator stopping", {
|
|
108
|
+
runningCount: this.running.size
|
|
109
|
+
});
|
|
110
|
+
if (this.pollTimer) {
|
|
111
|
+
clearTimeout(this.pollTimer);
|
|
112
|
+
this.pollTimer = null;
|
|
113
|
+
}
|
|
114
|
+
for (const [issueId, run] of this.running) {
|
|
115
|
+
if (run.process && !run.process.killed) {
|
|
116
|
+
logger.info("Killing agent process", {
|
|
117
|
+
issueId,
|
|
118
|
+
identifier: run.issue.identifier
|
|
119
|
+
});
|
|
120
|
+
run.process.kill("SIGTERM");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const deadline = Date.now() + 1e4;
|
|
124
|
+
while (this.running.size > 0 && Date.now() < deadline) {
|
|
125
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
126
|
+
}
|
|
127
|
+
for (const [_issueId, run] of this.running) {
|
|
128
|
+
if (run.process && !run.process.killed) {
|
|
129
|
+
run.process.kill("SIGKILL");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
this.running.clear();
|
|
133
|
+
this.claimed.clear();
|
|
134
|
+
console.log(
|
|
135
|
+
`Symphony stopped. Completed: ${this.completeCount}, Failed: ${this.failCount}`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Get current orchestrator stats.
|
|
140
|
+
*/
|
|
141
|
+
getStats() {
|
|
142
|
+
const issues = Array.from(this.running.values()).map((r) => ({
|
|
143
|
+
identifier: r.issue.identifier,
|
|
144
|
+
status: r.status,
|
|
145
|
+
attempt: r.attempt,
|
|
146
|
+
runtime: Date.now() - r.startedAt
|
|
147
|
+
}));
|
|
148
|
+
return {
|
|
149
|
+
running: this.running.size,
|
|
150
|
+
completed: this.completeCount,
|
|
151
|
+
failed: this.failCount,
|
|
152
|
+
totalAttempts: this.totalAttempts,
|
|
153
|
+
uptime: Date.now() - this.startedAt,
|
|
154
|
+
issues
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
// ── Polling ──
|
|
158
|
+
async schedulePoll() {
|
|
159
|
+
while (!this.stopping) {
|
|
160
|
+
await new Promise((resolve) => {
|
|
161
|
+
this.pollTimer = setTimeout(resolve, this.config.pollIntervalMs);
|
|
162
|
+
});
|
|
163
|
+
if (this.stopping) break;
|
|
164
|
+
try {
|
|
165
|
+
await this.poll();
|
|
166
|
+
} catch (err) {
|
|
167
|
+
logger.error("Poll cycle failed", { error: err.message });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async poll() {
|
|
172
|
+
if (!this.client || this.stopping) return;
|
|
173
|
+
await this.reconcile();
|
|
174
|
+
const available = this.config.maxConcurrent - this.running.size;
|
|
175
|
+
if (available <= 0) {
|
|
176
|
+
logger.debug("At capacity, skipping dispatch", {
|
|
177
|
+
running: this.running.size,
|
|
178
|
+
max: this.config.maxConcurrent
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const candidates = await this.fetchCandidates();
|
|
183
|
+
if (candidates.length === 0) return;
|
|
184
|
+
const eligible = candidates.filter(
|
|
185
|
+
(issue) => !this.claimed.has(issue.id) && !this.completed.has(issue.id)
|
|
186
|
+
);
|
|
187
|
+
if (eligible.length === 0) return;
|
|
188
|
+
const toDispatch = eligible.sort((a, b) => (a.priority || 4) - (b.priority || 4)).slice(0, available);
|
|
189
|
+
logger.info("Dispatching issues", {
|
|
190
|
+
count: toDispatch.length,
|
|
191
|
+
identifiers: toDispatch.map((i) => i.identifier)
|
|
192
|
+
});
|
|
193
|
+
for (const issue of toDispatch) {
|
|
194
|
+
this.dispatch(issue).catch((err) => {
|
|
195
|
+
logger.error("Dispatch failed", {
|
|
196
|
+
identifier: issue.identifier,
|
|
197
|
+
error: err.message
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async fetchCandidates() {
|
|
203
|
+
if (!this.client) return [];
|
|
204
|
+
const allCandidates = [];
|
|
205
|
+
const issues = await this.client.getIssues({
|
|
206
|
+
teamId: this.config.teamId,
|
|
207
|
+
limit: 50
|
|
208
|
+
});
|
|
209
|
+
const activeStatesLower = this.config.activeStates.map(
|
|
210
|
+
(s) => s.trim().toLowerCase()
|
|
211
|
+
);
|
|
212
|
+
for (const issue of issues) {
|
|
213
|
+
const stateName = issue.state.name.trim().toLowerCase();
|
|
214
|
+
if (activeStatesLower.includes(stateName)) {
|
|
215
|
+
allCandidates.push(issue);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return allCandidates;
|
|
219
|
+
}
|
|
220
|
+
// ── Dispatch ──
|
|
221
|
+
async dispatch(issue) {
|
|
222
|
+
const issueId = issue.id;
|
|
223
|
+
this.claimed.add(issueId);
|
|
224
|
+
const run = {
|
|
225
|
+
issue,
|
|
226
|
+
workspacePath: "",
|
|
227
|
+
process: null,
|
|
228
|
+
attempt: 1,
|
|
229
|
+
startedAt: Date.now(),
|
|
230
|
+
status: "starting"
|
|
231
|
+
};
|
|
232
|
+
this.running.set(issueId, run);
|
|
233
|
+
this.totalAttempts++;
|
|
234
|
+
console.log(`[${issue.identifier}] Dispatching: ${issue.title}`);
|
|
235
|
+
try {
|
|
236
|
+
const workspacePath = await this.createWorkspace(issue);
|
|
237
|
+
run.workspacePath = workspacePath;
|
|
238
|
+
await this.transitionIssue(issue, this.config.inProgressState);
|
|
239
|
+
await this.runHook("after-create", workspacePath, issue);
|
|
240
|
+
run.status = "running";
|
|
241
|
+
await this.runAgent(issue, run);
|
|
242
|
+
run.status = "completed";
|
|
243
|
+
this.completeCount++;
|
|
244
|
+
await this.runHook("after-run", workspacePath, issue, run.attempt);
|
|
245
|
+
await this.transitionIssue(issue, this.config.inReviewState);
|
|
246
|
+
console.log(`[${issue.identifier}] Completed successfully`);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
run.status = "failed";
|
|
249
|
+
run.error = err.message;
|
|
250
|
+
logger.error("Issue dispatch failed", {
|
|
251
|
+
identifier: issue.identifier,
|
|
252
|
+
error: run.error,
|
|
253
|
+
attempt: run.attempt
|
|
254
|
+
});
|
|
255
|
+
if (run.workspacePath) {
|
|
256
|
+
await this.runHook(
|
|
257
|
+
"after-run",
|
|
258
|
+
run.workspacePath,
|
|
259
|
+
issue,
|
|
260
|
+
run.attempt
|
|
261
|
+
).catch(() => {
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (run.attempt < this.config.maxRetries + 1) {
|
|
265
|
+
console.log(
|
|
266
|
+
`[${issue.identifier}] Failed (attempt ${run.attempt}), retrying...`
|
|
267
|
+
);
|
|
268
|
+
run.attempt++;
|
|
269
|
+
this.totalAttempts++;
|
|
270
|
+
const backoffMs = Math.min(1e3 * Math.pow(2, run.attempt - 1), 3e5);
|
|
271
|
+
await new Promise((r) => setTimeout(r, backoffMs));
|
|
272
|
+
if (!this.stopping) {
|
|
273
|
+
try {
|
|
274
|
+
run.status = "running";
|
|
275
|
+
await this.runAgent(issue, run);
|
|
276
|
+
run.status = "completed";
|
|
277
|
+
this.completeCount++;
|
|
278
|
+
await this.runHook(
|
|
279
|
+
"after-run",
|
|
280
|
+
run.workspacePath,
|
|
281
|
+
issue,
|
|
282
|
+
run.attempt
|
|
283
|
+
).catch(() => {
|
|
284
|
+
});
|
|
285
|
+
await this.transitionIssue(issue, this.config.inReviewState);
|
|
286
|
+
console.log(
|
|
287
|
+
`[${issue.identifier}] Completed on retry ${run.attempt}`
|
|
288
|
+
);
|
|
289
|
+
} catch (retryErr) {
|
|
290
|
+
run.status = "failed";
|
|
291
|
+
run.error = retryErr.message;
|
|
292
|
+
this.failCount++;
|
|
293
|
+
console.log(
|
|
294
|
+
`[${issue.identifier}] Failed after ${run.attempt} attempts: ${run.error}`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
this.failCount++;
|
|
300
|
+
console.log(`[${issue.identifier}] Failed: ${run.error}`);
|
|
301
|
+
}
|
|
302
|
+
} finally {
|
|
303
|
+
this.running.delete(issueId);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// ── Workspace Management ──
|
|
307
|
+
async createWorkspace(issue) {
|
|
308
|
+
const wsKey = this.sanitizeIdentifier(issue.identifier);
|
|
309
|
+
const wsPath = join(this.config.workspaceRoot, wsKey);
|
|
310
|
+
if (existsSync(wsPath)) {
|
|
311
|
+
logger.info("Reusing existing workspace", {
|
|
312
|
+
identifier: issue.identifier,
|
|
313
|
+
path: wsPath
|
|
314
|
+
});
|
|
315
|
+
return wsPath;
|
|
316
|
+
}
|
|
317
|
+
const branchName = `symphony/${wsKey}`;
|
|
318
|
+
try {
|
|
319
|
+
execSync("git fetch origin", {
|
|
320
|
+
cwd: this.config.repoRoot,
|
|
321
|
+
stdio: "pipe",
|
|
322
|
+
timeout: 3e4
|
|
323
|
+
});
|
|
324
|
+
execSync(
|
|
325
|
+
`git worktree add "${wsPath}" -b "${branchName}" "origin/${this.config.baseBranch}"`,
|
|
326
|
+
{
|
|
327
|
+
cwd: this.config.repoRoot,
|
|
328
|
+
stdio: "pipe",
|
|
329
|
+
timeout: 3e4
|
|
330
|
+
}
|
|
331
|
+
);
|
|
332
|
+
logger.info("Created workspace", {
|
|
333
|
+
identifier: issue.identifier,
|
|
334
|
+
path: wsPath,
|
|
335
|
+
branch: branchName
|
|
336
|
+
});
|
|
337
|
+
} catch (err) {
|
|
338
|
+
try {
|
|
339
|
+
execSync(`git worktree add "${wsPath}" "${branchName}"`, {
|
|
340
|
+
cwd: this.config.repoRoot,
|
|
341
|
+
stdio: "pipe",
|
|
342
|
+
timeout: 3e4
|
|
343
|
+
});
|
|
344
|
+
} catch {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`Failed to create workspace for ${issue.identifier}: ${err.message}`
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return wsPath;
|
|
351
|
+
}
|
|
352
|
+
async removeWorkspace(issue) {
|
|
353
|
+
const wsKey = this.sanitizeIdentifier(issue.identifier);
|
|
354
|
+
const wsPath = join(this.config.workspaceRoot, wsKey);
|
|
355
|
+
if (!existsSync(wsPath)) return;
|
|
356
|
+
await this.runHook("before-remove", wsPath, issue).catch(() => {
|
|
357
|
+
});
|
|
358
|
+
try {
|
|
359
|
+
execSync(`git worktree remove "${wsPath}" --force`, {
|
|
360
|
+
cwd: this.config.repoRoot,
|
|
361
|
+
stdio: "pipe",
|
|
362
|
+
timeout: 3e4
|
|
363
|
+
});
|
|
364
|
+
} catch {
|
|
365
|
+
try {
|
|
366
|
+
rmSync(wsPath, { recursive: true, force: true });
|
|
367
|
+
execSync("git worktree prune", {
|
|
368
|
+
cwd: this.config.repoRoot,
|
|
369
|
+
stdio: "pipe",
|
|
370
|
+
timeout: 1e4
|
|
371
|
+
});
|
|
372
|
+
} catch {
|
|
373
|
+
logger.warn("Failed to clean workspace", {
|
|
374
|
+
identifier: issue.identifier,
|
|
375
|
+
path: wsPath
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
sanitizeIdentifier(identifier) {
|
|
381
|
+
return identifier.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
382
|
+
}
|
|
383
|
+
// ── Agent Execution ──
|
|
384
|
+
runAgent(issue, run) {
|
|
385
|
+
return new Promise((resolve, reject) => {
|
|
386
|
+
const prompt = this.buildPrompt(issue, run.attempt);
|
|
387
|
+
const proc = spawn("node", [this.config.appServerPath], {
|
|
388
|
+
cwd: run.workspacePath,
|
|
389
|
+
env: {
|
|
390
|
+
...process.env,
|
|
391
|
+
SYMPHONY_WORKSPACE_DIR: run.workspacePath,
|
|
392
|
+
SYMPHONY_ISSUE_ID: issue.id,
|
|
393
|
+
SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
|
|
394
|
+
SYMPHONY_ATTEMPT: String(run.attempt)
|
|
395
|
+
},
|
|
396
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
397
|
+
});
|
|
398
|
+
run.process = proc;
|
|
399
|
+
let stderr = "";
|
|
400
|
+
let turnCompleted = false;
|
|
401
|
+
let timer;
|
|
402
|
+
timer = setTimeout(() => {
|
|
403
|
+
if (!turnCompleted) {
|
|
404
|
+
logger.warn("Agent turn timeout", {
|
|
405
|
+
identifier: issue.identifier,
|
|
406
|
+
timeoutMs: this.config.turnTimeoutMs
|
|
407
|
+
});
|
|
408
|
+
proc.kill("SIGTERM");
|
|
409
|
+
reject(
|
|
410
|
+
new Error(`Agent timeout after ${this.config.turnTimeoutMs}ms`)
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}, this.config.turnTimeoutMs);
|
|
414
|
+
const send = (msg) => {
|
|
415
|
+
proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
416
|
+
};
|
|
417
|
+
let lineBuffer = "";
|
|
418
|
+
proc.stdout.on("data", (chunk) => {
|
|
419
|
+
lineBuffer += chunk.toString();
|
|
420
|
+
const lines = lineBuffer.split("\n");
|
|
421
|
+
lineBuffer = lines.pop() || "";
|
|
422
|
+
for (const line of lines) {
|
|
423
|
+
if (!line.trim()) continue;
|
|
424
|
+
try {
|
|
425
|
+
const msg = JSON.parse(line);
|
|
426
|
+
this.handleAgentMessage(msg, issue, run);
|
|
427
|
+
if (msg.method === "turn/completed") {
|
|
428
|
+
turnCompleted = true;
|
|
429
|
+
}
|
|
430
|
+
if (msg.method === "turn/failed") {
|
|
431
|
+
turnCompleted = true;
|
|
432
|
+
const errMsg = msg.params?.error?.message || "Agent turn failed";
|
|
433
|
+
clearTimeout(timer);
|
|
434
|
+
reject(new Error(errMsg));
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
} catch {
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
proc.stderr.on("data", (data) => {
|
|
442
|
+
stderr += data.toString();
|
|
443
|
+
const lines = data.toString().split("\n").filter((l) => l.trim());
|
|
444
|
+
for (const line of lines) {
|
|
445
|
+
logger.debug(`[${issue.identifier}] ${line}`);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
proc.on("error", (err) => {
|
|
449
|
+
clearTimeout(timer);
|
|
450
|
+
reject(new Error(`Failed to spawn agent: ${err.message}`));
|
|
451
|
+
});
|
|
452
|
+
proc.on("close", (code) => {
|
|
453
|
+
clearTimeout(timer);
|
|
454
|
+
run.process = null;
|
|
455
|
+
if (turnCompleted) {
|
|
456
|
+
resolve();
|
|
457
|
+
} else if (code === 0) {
|
|
458
|
+
resolve();
|
|
459
|
+
} else {
|
|
460
|
+
reject(
|
|
461
|
+
new Error(`Agent exited with code ${code}: ${stderr.slice(0, 500)}`)
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
|
|
466
|
+
setTimeout(() => {
|
|
467
|
+
send({
|
|
468
|
+
jsonrpc: "2.0",
|
|
469
|
+
id: 2,
|
|
470
|
+
method: "thread/start",
|
|
471
|
+
params: { cwd: run.workspacePath }
|
|
472
|
+
});
|
|
473
|
+
setTimeout(() => {
|
|
474
|
+
send({
|
|
475
|
+
jsonrpc: "2.0",
|
|
476
|
+
id: 3,
|
|
477
|
+
method: "turn/start",
|
|
478
|
+
params: {
|
|
479
|
+
cwd: run.workspacePath,
|
|
480
|
+
input: [{ type: "text", text: prompt }]
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
}, 100);
|
|
484
|
+
}, 100);
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
handleAgentMessage(msg, issue, _run) {
|
|
488
|
+
if (msg.method === "item/commandExecution/started") {
|
|
489
|
+
logger.debug("Agent tool use", {
|
|
490
|
+
identifier: issue.identifier,
|
|
491
|
+
tool: msg.params?.tool
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
if (msg.method === "turn/completed") {
|
|
495
|
+
const output = msg.params?.result?.output;
|
|
496
|
+
if (Array.isArray(output)) {
|
|
497
|
+
const text = output.filter((b) => b.type === "text").map((b) => b.text).join("\n");
|
|
498
|
+
if (text) {
|
|
499
|
+
logger.info("Agent completed", {
|
|
500
|
+
identifier: issue.identifier,
|
|
501
|
+
outputLength: text.length
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
buildPrompt(issue, attempt) {
|
|
508
|
+
const lines = [
|
|
509
|
+
`You are working on Linear issue ${issue.identifier}: ${issue.title}`,
|
|
510
|
+
""
|
|
511
|
+
];
|
|
512
|
+
if (issue.description) {
|
|
513
|
+
lines.push("## Description", "", issue.description, "");
|
|
514
|
+
}
|
|
515
|
+
if (issue.labels.length > 0) {
|
|
516
|
+
lines.push(`Labels: ${issue.labels.map((l) => l.name).join(", ")}`);
|
|
517
|
+
}
|
|
518
|
+
lines.push(
|
|
519
|
+
`Priority: ${["None", "Urgent", "High", "Medium", "Low"][issue.priority] || "None"}`
|
|
520
|
+
);
|
|
521
|
+
if (attempt > 1) {
|
|
522
|
+
lines.push(
|
|
523
|
+
"",
|
|
524
|
+
`This is attempt ${attempt}. Check .stackmemory/symphony-context.md for context from prior attempts.`
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
lines.push(
|
|
528
|
+
"",
|
|
529
|
+
"## Instructions",
|
|
530
|
+
"",
|
|
531
|
+
"1. Read the issue description carefully",
|
|
532
|
+
"2. Implement the requested changes",
|
|
533
|
+
"3. Write or update tests as needed",
|
|
534
|
+
"4. Run lint and tests to verify",
|
|
535
|
+
"5. Commit your changes with a descriptive message",
|
|
536
|
+
"",
|
|
537
|
+
"Work in the current directory. All changes will be on a dedicated branch."
|
|
538
|
+
);
|
|
539
|
+
return lines.join("\n");
|
|
540
|
+
}
|
|
541
|
+
// ── Hooks ──
|
|
542
|
+
async runHook(hookName, workspacePath, issue, attempt) {
|
|
543
|
+
const hookPath = join(
|
|
544
|
+
this.config.repoRoot,
|
|
545
|
+
"scripts",
|
|
546
|
+
"symphony",
|
|
547
|
+
`${hookName}.sh`
|
|
548
|
+
);
|
|
549
|
+
if (!existsSync(hookPath)) {
|
|
550
|
+
logger.debug("Hook not found, skipping", { hookName, hookPath });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
logger.debug("Running hook", { hookName, identifier: issue.identifier });
|
|
554
|
+
try {
|
|
555
|
+
execSync(`bash "${hookPath}"`, {
|
|
556
|
+
cwd: workspacePath,
|
|
557
|
+
timeout: this.config.hookTimeoutMs,
|
|
558
|
+
stdio: "pipe",
|
|
559
|
+
env: {
|
|
560
|
+
...process.env,
|
|
561
|
+
SYMPHONY_WORKSPACE_DIR: workspacePath,
|
|
562
|
+
SYMPHONY_ISSUE_ID: issue.id,
|
|
563
|
+
SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
|
|
564
|
+
SYMPHONY_ATTEMPT: String(attempt || 1)
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
} catch (err) {
|
|
568
|
+
logger.warn("Hook failed", {
|
|
569
|
+
hookName,
|
|
570
|
+
identifier: issue.identifier,
|
|
571
|
+
error: err.message
|
|
572
|
+
});
|
|
573
|
+
if (hookName === "after-create") {
|
|
574
|
+
throw new Error(`Hook ${hookName} failed: ${err.message}`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
// ── State Transitions ──
|
|
579
|
+
async cacheWorkflowStates() {
|
|
580
|
+
if (!this.client || !this.config.teamId) return;
|
|
581
|
+
try {
|
|
582
|
+
const states = await this.client.getWorkflowStates(this.config.teamId);
|
|
583
|
+
for (const state of states) {
|
|
584
|
+
this.stateCache.set(state.name.trim().toLowerCase(), {
|
|
585
|
+
id: state.id,
|
|
586
|
+
name: state.name
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
logger.debug("Cached workflow states", { count: this.stateCache.size });
|
|
590
|
+
} catch (err) {
|
|
591
|
+
logger.warn("Failed to cache workflow states", {
|
|
592
|
+
error: err.message
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
async transitionIssue(issue, targetStateName) {
|
|
597
|
+
if (!this.client) return;
|
|
598
|
+
const stateKey = targetStateName.trim().toLowerCase();
|
|
599
|
+
const state = this.stateCache.get(stateKey);
|
|
600
|
+
if (!state) {
|
|
601
|
+
logger.warn("Target state not found in cache", {
|
|
602
|
+
targetState: targetStateName,
|
|
603
|
+
available: Array.from(this.stateCache.keys())
|
|
604
|
+
});
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
try {
|
|
608
|
+
await this.client.updateIssueState(issue.id, state.id);
|
|
609
|
+
logger.info("Transitioned issue", {
|
|
610
|
+
identifier: issue.identifier,
|
|
611
|
+
from: issue.state.name,
|
|
612
|
+
to: state.name
|
|
613
|
+
});
|
|
614
|
+
} catch (err) {
|
|
615
|
+
logger.warn("Failed to transition issue", {
|
|
616
|
+
identifier: issue.identifier,
|
|
617
|
+
targetState: state.name,
|
|
618
|
+
error: err.message
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// ── Reconciliation ──
|
|
623
|
+
async reconcile() {
|
|
624
|
+
if (!this.client || this.running.size === 0) return;
|
|
625
|
+
const terminalLower = this.config.terminalStates.map(
|
|
626
|
+
(s) => s.trim().toLowerCase()
|
|
627
|
+
);
|
|
628
|
+
for (const [issueId, run] of this.running) {
|
|
629
|
+
try {
|
|
630
|
+
const issues = await this.client.getIssues({ limit: 1 });
|
|
631
|
+
const fresh = issues.find((i) => i.id === issueId);
|
|
632
|
+
if (!fresh) continue;
|
|
633
|
+
const currentState = fresh.state.name.trim().toLowerCase();
|
|
634
|
+
if (terminalLower.includes(currentState)) {
|
|
635
|
+
logger.info(
|
|
636
|
+
"Issue moved to terminal state externally, stopping agent",
|
|
637
|
+
{
|
|
638
|
+
identifier: run.issue.identifier,
|
|
639
|
+
state: fresh.state.name
|
|
640
|
+
}
|
|
641
|
+
);
|
|
642
|
+
if (run.process && !run.process.killed) {
|
|
643
|
+
run.process.kill("SIGTERM");
|
|
644
|
+
}
|
|
645
|
+
await this.removeWorkspace(run.issue);
|
|
646
|
+
this.running.delete(issueId);
|
|
647
|
+
this.completed.add(issueId);
|
|
648
|
+
}
|
|
649
|
+
} catch (err) {
|
|
650
|
+
logger.debug("Reconciliation check failed for issue", {
|
|
651
|
+
issueId,
|
|
652
|
+
error: err.message
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
// ── Linear Client ──
|
|
658
|
+
async createLinearClient() {
|
|
659
|
+
try {
|
|
660
|
+
const authManager = new LinearAuthManager(this.config.repoRoot);
|
|
661
|
+
const token = await authManager.getValidToken();
|
|
662
|
+
return new LinearClient({ apiKey: token, useBearer: true });
|
|
663
|
+
} catch {
|
|
664
|
+
const apiKey = process.env.LINEAR_API_KEY;
|
|
665
|
+
if (!apiKey) {
|
|
666
|
+
throw new Error(
|
|
667
|
+
"Linear authentication required. Run `stackmemory linear setup` or set LINEAR_API_KEY."
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
return new LinearClient({ apiKey });
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
export {
|
|
675
|
+
SymphonyOrchestrator
|
|
676
|
+
};
|
|
@@ -5,9 +5,10 @@ const __dirname = __pathDirname(__filename);
|
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
7
7
|
import { join } from "path";
|
|
8
|
-
import { homedir } from "os";
|
|
8
|
+
import { homedir, tmpdir } from "os";
|
|
9
9
|
import Database from "better-sqlite3";
|
|
10
10
|
import { logger } from "../../core/monitoring/logger.js";
|
|
11
|
+
import { SymphonyOrchestrator } from "./symphony-orchestrator.js";
|
|
11
12
|
function getGlobalStorePath() {
|
|
12
13
|
const dir = join(homedir(), ".stackmemory", "symphony");
|
|
13
14
|
if (!existsSync(dir)) {
|
|
@@ -260,6 +261,34 @@ function createSymphonyCommands() {
|
|
|
260
261
|
}
|
|
261
262
|
globalDb.close();
|
|
262
263
|
});
|
|
264
|
+
cmd.command("start").description("Start the Symphony orchestrator daemon").option("--team <id>", "Linear team ID").option(
|
|
265
|
+
"--states <states>",
|
|
266
|
+
"Comma-separated issue states to pick up",
|
|
267
|
+
"Todo"
|
|
268
|
+
).option(
|
|
269
|
+
"--in-progress <state>",
|
|
270
|
+
"State name for in-progress",
|
|
271
|
+
"In Progress"
|
|
272
|
+
).option(
|
|
273
|
+
"--in-review <state>",
|
|
274
|
+
"State name for completed review",
|
|
275
|
+
"In Review"
|
|
276
|
+
).option("--poll <ms>", "Polling interval in milliseconds", "30000").option("--concurrency <n>", "Max concurrent agents", "3").option("--workspace-root <path>", "Workspace root directory").option("--repo <path>", "Git repo root for worktrees", process.cwd()).option("--branch <name>", "Base branch for worktrees", "main").option("--retries <n>", "Max retries per issue", "1").option("--turn-timeout <ms>", "Agent turn timeout in ms", "3600000").action(async (options) => {
|
|
277
|
+
const orchestrator = new SymphonyOrchestrator({
|
|
278
|
+
teamId: options.team,
|
|
279
|
+
activeStates: options.states.split(",").map((s) => s.trim()),
|
|
280
|
+
inProgressState: options.inProgress,
|
|
281
|
+
inReviewState: options.inReview,
|
|
282
|
+
pollIntervalMs: parseInt(options.poll, 10),
|
|
283
|
+
maxConcurrent: parseInt(options.concurrency, 10),
|
|
284
|
+
workspaceRoot: options.workspaceRoot || join(tmpdir(), "symphony_workspaces"),
|
|
285
|
+
repoRoot: options.repo,
|
|
286
|
+
baseBranch: options.branch,
|
|
287
|
+
maxRetries: parseInt(options.retries, 10),
|
|
288
|
+
turnTimeoutMs: parseInt(options.turnTimeout, 10)
|
|
289
|
+
});
|
|
290
|
+
await orchestrator.start();
|
|
291
|
+
});
|
|
263
292
|
return cmd;
|
|
264
293
|
}
|
|
265
294
|
export {
|
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
import { AnthropicBatchClient } from "../anthropic/batch-client.js";
|
|
20
20
|
class ClaudeCodeSubagentClient {
|
|
21
21
|
tempDir;
|
|
22
|
-
activeSubagents = /* @__PURE__ */ new Map();
|
|
23
22
|
mockMode;
|
|
24
23
|
constructor(mockMode = false) {
|
|
25
24
|
this.mockMode = mockMode;
|
|
@@ -344,13 +343,7 @@ Context (JSON): ${JSON.stringify(request.context)}`;
|
|
|
344
343
|
*/
|
|
345
344
|
spawnClaude(prompt, timeout) {
|
|
346
345
|
return new Promise((resolve, reject) => {
|
|
347
|
-
const args = [
|
|
348
|
-
"-p",
|
|
349
|
-
"--output-format",
|
|
350
|
-
"stream-json",
|
|
351
|
-
"--dangerously-skip-permissions",
|
|
352
|
-
prompt
|
|
353
|
-
];
|
|
346
|
+
const args = ["-p", "--output-format", "stream-json", prompt];
|
|
354
347
|
const claude = spawn("claude", args, {
|
|
355
348
|
cwd: process.cwd(),
|
|
356
349
|
env: { ...process.env },
|
|
@@ -548,84 +541,11 @@ function greetUser(name: string): string {
|
|
|
548
541
|
}
|
|
549
542
|
}
|
|
550
543
|
}
|
|
551
|
-
/**
|
|
552
|
-
* Create a mock Task tool response for development
|
|
553
|
-
* This simulates what Claude Code's Task tool would return
|
|
554
|
-
*/
|
|
555
|
-
async mockTaskToolExecution(request) {
|
|
556
|
-
const startTime = Date.now();
|
|
557
|
-
await new Promise(
|
|
558
|
-
(resolve) => setTimeout(resolve, 1e3 + Math.random() * 2e3)
|
|
559
|
-
);
|
|
560
|
-
const mockResponses = {
|
|
561
|
-
planning: {
|
|
562
|
-
tasks: [
|
|
563
|
-
{ id: "1", type: "analyze", description: "Analyze requirements" },
|
|
564
|
-
{ id: "2", type: "implement", description: "Implement solution" },
|
|
565
|
-
{ id: "3", type: "test", description: "Test implementation" },
|
|
566
|
-
{ id: "4", type: "review", description: "Review and improve" }
|
|
567
|
-
],
|
|
568
|
-
dependencies: { "2": ["1"], "3": ["2"], "4": ["3"] }
|
|
569
|
-
},
|
|
570
|
-
code: {
|
|
571
|
-
implementation: `
|
|
572
|
-
export class Solution {
|
|
573
|
-
constructor(private config: any) {}
|
|
574
|
-
|
|
575
|
-
async execute(input: string): Promise<string> {
|
|
576
|
-
// Implementation generated by Code subagent
|
|
577
|
-
return this.process(input);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
private process(input: string): string {
|
|
581
|
-
return \`Processed: \${input}\`;
|
|
582
|
-
}
|
|
583
|
-
}`,
|
|
584
|
-
files: ["src/solution.ts"]
|
|
585
|
-
},
|
|
586
|
-
testing: {
|
|
587
|
-
tests: `
|
|
588
|
-
describe('Solution', () => {
|
|
589
|
-
it('should process input correctly', () => {
|
|
590
|
-
const solution = new Solution({});
|
|
591
|
-
const result = solution.execute('test');
|
|
592
|
-
expect(result).toBe('Processed: test');
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
it('should handle edge cases', () => {
|
|
596
|
-
// Edge case tests
|
|
597
|
-
});
|
|
598
|
-
});`,
|
|
599
|
-
coverage: { lines: 95, branches: 88, functions: 100 }
|
|
600
|
-
},
|
|
601
|
-
review: {
|
|
602
|
-
quality: 0.82,
|
|
603
|
-
issues: [
|
|
604
|
-
{ severity: "high", message: "Missing error handling" },
|
|
605
|
-
{ severity: "medium", message: "Could improve type safety" }
|
|
606
|
-
],
|
|
607
|
-
suggestions: [
|
|
608
|
-
"Add try-catch blocks",
|
|
609
|
-
"Use stricter TypeScript types",
|
|
610
|
-
"Add input validation"
|
|
611
|
-
]
|
|
612
|
-
}
|
|
613
|
-
};
|
|
614
|
-
return {
|
|
615
|
-
success: true,
|
|
616
|
-
result: mockResponses[request.type] || { status: "completed" },
|
|
617
|
-
output: `Mock ${request.type} subagent completed successfully`,
|
|
618
|
-
duration: Date.now() - startTime,
|
|
619
|
-
subagentType: request.type,
|
|
620
|
-
tokens: Math.floor(Math.random() * 5e3) + 1e3
|
|
621
|
-
};
|
|
622
|
-
}
|
|
623
544
|
/**
|
|
624
545
|
* Get active subagent statistics
|
|
625
546
|
*/
|
|
626
547
|
getStats() {
|
|
627
548
|
return {
|
|
628
|
-
activeSubagents: this.activeSubagents.size,
|
|
629
549
|
tempDir: this.tempDir
|
|
630
550
|
};
|
|
631
551
|
}
|
|
@@ -633,10 +553,6 @@ describe('Solution', () => {
|
|
|
633
553
|
* Cleanup all resources
|
|
634
554
|
*/
|
|
635
555
|
async cleanupAll() {
|
|
636
|
-
for (const [id, controller] of this.activeSubagents) {
|
|
637
|
-
controller.abort();
|
|
638
|
-
}
|
|
639
|
-
this.activeSubagents.clear();
|
|
640
556
|
if (fs.existsSync(this.tempDir)) {
|
|
641
557
|
const files = await fs.promises.readdir(this.tempDir);
|
|
642
558
|
for (const file of files) {
|
|
@@ -166,11 +166,16 @@ class ClaudeCodeTaskCoordinator {
|
|
|
166
166
|
completedTasks: this.completedTasks.length
|
|
167
167
|
});
|
|
168
168
|
if (this.activeTasks.size > 0) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
let timer;
|
|
170
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
171
|
+
timer = setTimeout(resolve, 3e4);
|
|
172
|
+
});
|
|
172
173
|
const completionPromise = this.waitForTaskCompletion();
|
|
173
|
-
|
|
174
|
+
try {
|
|
175
|
+
await Promise.race([completionPromise, timeoutPromise]);
|
|
176
|
+
} finally {
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
}
|
|
174
179
|
if (this.activeTasks.size > 0) {
|
|
175
180
|
logger.warn("Force terminating active tasks", {
|
|
176
181
|
remainingTasks: this.activeTasks.size
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stackmemoryai/stackmemory",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"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.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0",
|