@stackmemoryai/stackmemory 1.5.7 → 1.5.8
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.
|
@@ -16,7 +16,9 @@ import { homedir, tmpdir } from "os";
|
|
|
16
16
|
import Database from "better-sqlite3";
|
|
17
17
|
import { logger } from "../../core/monitoring/logger.js";
|
|
18
18
|
import { Conductor } from "./orchestrator.js";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
getAgentStatusDir
|
|
21
|
+
} from "./orchestrator.js";
|
|
20
22
|
function getGlobalStorePath() {
|
|
21
23
|
const dir = join(homedir(), ".stackmemory", "conductor");
|
|
22
24
|
if (!existsSync(dir)) {
|
|
@@ -73,6 +75,101 @@ function budgetBar(pct, width = 30) {
|
|
|
73
75
|
const rst = "\x1B[0m";
|
|
74
76
|
return `${color}${"\u2588".repeat(filled)}${dim}${"\u2591".repeat(empty)}${rst} ${String(pct).padStart(3)}%`;
|
|
75
77
|
}
|
|
78
|
+
const c = {
|
|
79
|
+
r: "\x1B[0m",
|
|
80
|
+
// reset
|
|
81
|
+
b: "\x1B[1m",
|
|
82
|
+
// bold
|
|
83
|
+
d: "\x1B[2m",
|
|
84
|
+
// dim
|
|
85
|
+
i: "\x1B[3m",
|
|
86
|
+
// italic
|
|
87
|
+
u: "\x1B[4m",
|
|
88
|
+
// underline
|
|
89
|
+
// Linear-inspired palette
|
|
90
|
+
purple: "\x1B[38;5;141m",
|
|
91
|
+
blue: "\x1B[38;5;75m",
|
|
92
|
+
cyan: "\x1B[38;5;80m",
|
|
93
|
+
green: "\x1B[38;5;114m",
|
|
94
|
+
yellow: "\x1B[38;5;221m",
|
|
95
|
+
orange: "\x1B[38;5;215m",
|
|
96
|
+
red: "\x1B[38;5;203m",
|
|
97
|
+
pink: "\x1B[38;5;176m",
|
|
98
|
+
gray: "\x1B[38;5;245m",
|
|
99
|
+
white: "\x1B[38;5;255m",
|
|
100
|
+
bg: {
|
|
101
|
+
purple: "\x1B[48;5;53m",
|
|
102
|
+
blue: "\x1B[48;5;24m",
|
|
103
|
+
green: "\x1B[48;5;22m",
|
|
104
|
+
red: "\x1B[48;5;52m",
|
|
105
|
+
yellow: "\x1B[48;5;58m",
|
|
106
|
+
gray: "\x1B[48;5;236m"
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const phaseIcon = {
|
|
110
|
+
reading: "\u25D4",
|
|
111
|
+
planning: "\u25D1",
|
|
112
|
+
implementing: "\u25D5",
|
|
113
|
+
testing: "\u25CF",
|
|
114
|
+
committing: "\u2713"
|
|
115
|
+
};
|
|
116
|
+
const phaseColor = {
|
|
117
|
+
reading: c.cyan,
|
|
118
|
+
planning: c.blue,
|
|
119
|
+
implementing: c.yellow,
|
|
120
|
+
testing: c.pink,
|
|
121
|
+
committing: c.green
|
|
122
|
+
};
|
|
123
|
+
function isProcessAlive(pid) {
|
|
124
|
+
try {
|
|
125
|
+
process.kill(pid, 0);
|
|
126
|
+
return true;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function phaseProgress(phase, toolCalls, stale, alive) {
|
|
132
|
+
const basePct = {
|
|
133
|
+
reading: 10,
|
|
134
|
+
planning: 25,
|
|
135
|
+
implementing: 50,
|
|
136
|
+
testing: 75,
|
|
137
|
+
committing: 90
|
|
138
|
+
};
|
|
139
|
+
let pct = basePct[phase] || 0;
|
|
140
|
+
if (phase === "implementing") {
|
|
141
|
+
pct += Math.min(Math.floor(toolCalls / 80 * 25), 25);
|
|
142
|
+
}
|
|
143
|
+
if (phase === "committing") {
|
|
144
|
+
pct = 90 + Math.min(Math.floor(toolCalls / 60 * 10), 9);
|
|
145
|
+
}
|
|
146
|
+
const labels = {
|
|
147
|
+
reading: "Reading",
|
|
148
|
+
planning: "Planning",
|
|
149
|
+
implementing: "Implementing",
|
|
150
|
+
testing: "Testing",
|
|
151
|
+
committing: "Committing"
|
|
152
|
+
};
|
|
153
|
+
let label = labels[phase] || phase;
|
|
154
|
+
let color = phaseColor[phase] || "";
|
|
155
|
+
let icon = phaseIcon[phase] || "\u25CB";
|
|
156
|
+
if (!alive) {
|
|
157
|
+
label = "Dead";
|
|
158
|
+
color = c.red;
|
|
159
|
+
icon = "\u2717";
|
|
160
|
+
} else if (stale) {
|
|
161
|
+
label = "Stalled";
|
|
162
|
+
color = c.orange;
|
|
163
|
+
icon = "\u23F8";
|
|
164
|
+
}
|
|
165
|
+
return { icon, color, pct, label };
|
|
166
|
+
}
|
|
167
|
+
function progressBar(pct, width) {
|
|
168
|
+
const filled = Math.min(Math.round(pct / 100 * width), width);
|
|
169
|
+
const empty = width - filled;
|
|
170
|
+
const col = pct >= 90 ? c.green : pct >= 50 ? c.yellow : c.cyan;
|
|
171
|
+
return `${col}${"\u2501".repeat(filled)}${c.d}${"\u254C".repeat(empty)}${c.r}`;
|
|
172
|
+
}
|
|
76
173
|
function fmtMinutes(m) {
|
|
77
174
|
if (m < 0) return "N/A";
|
|
78
175
|
if (m >= 60) return `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
@@ -90,23 +187,23 @@ function printUsageSummary(u) {
|
|
|
90
187
|
const mins20x = u.minutesRemaining20x ?? -1;
|
|
91
188
|
const cacheHitRate = u.cacheHitRate || 0;
|
|
92
189
|
const b = "\x1B[1m";
|
|
93
|
-
const
|
|
190
|
+
const d2 = "\x1B[2m";
|
|
94
191
|
const w = "\x1B[37m";
|
|
95
|
-
const
|
|
96
|
-
console.log(`${b}Token Usage${
|
|
192
|
+
const r2 = "\x1B[0m";
|
|
193
|
+
console.log(`${b}Token Usage${r2}`);
|
|
97
194
|
console.log(
|
|
98
|
-
` Input ${w}${fmtTokens(inputTokens)}${
|
|
195
|
+
` Input ${w}${fmtTokens(inputTokens)}${r2} ${d2}|${r2} Output ${w}${fmtTokens(outputTokens)}${r2} ${d2}|${r2} Total ${w}${fmtTokens(totalTokens)}${r2}`
|
|
99
196
|
);
|
|
100
197
|
console.log(
|
|
101
|
-
` Rate ${w}${fmtTokens(tokensPerMin)}/min${
|
|
198
|
+
` Rate ${w}${fmtTokens(tokensPerMin)}/min${r2} ${d2}|${r2} Messages ${w}${estMessages}${r2} ${d2}|${r2} Cache hit ${w}${cacheHitRate}%${r2}`
|
|
102
199
|
);
|
|
103
200
|
console.log("");
|
|
104
|
-
console.log(`${b}Budget (Max plan, 5h window)${
|
|
201
|
+
console.log(`${b}Budget (Max plan, 5h window)${r2}`);
|
|
105
202
|
console.log(
|
|
106
|
-
` 5x (225 msgs) ${budgetBar(budgetPct5x)} ${
|
|
203
|
+
` 5x (225 msgs) ${budgetBar(budgetPct5x)} ${d2}~${fmtMinutes(mins5x)} left${r2}`
|
|
107
204
|
);
|
|
108
205
|
console.log(
|
|
109
|
-
` 20x (900 msgs) ${budgetBar(budgetPct20x)} ${
|
|
206
|
+
` 20x (900 msgs) ${budgetBar(budgetPct20x)} ${d2}~${fmtMinutes(mins20x)} left${r2}`
|
|
110
207
|
);
|
|
111
208
|
}
|
|
112
209
|
function createConductorCommands() {
|
|
@@ -226,14 +323,14 @@ function createConductorCommands() {
|
|
|
226
323
|
const risks = anchors.filter((a) => a.type === "RISK");
|
|
227
324
|
if (decisions.length > 0) {
|
|
228
325
|
lines.push("", "### Decisions");
|
|
229
|
-
for (const
|
|
230
|
-
lines.push(`- ${
|
|
326
|
+
for (const d2 of decisions.slice(0, 10)) {
|
|
327
|
+
lines.push(`- ${d2.text}`);
|
|
231
328
|
}
|
|
232
329
|
}
|
|
233
330
|
if (risks.length > 0) {
|
|
234
331
|
lines.push("", "### Risks");
|
|
235
|
-
for (const
|
|
236
|
-
lines.push(`- ${
|
|
332
|
+
for (const r2 of risks.slice(0, 5)) {
|
|
333
|
+
lines.push(`- ${r2.text}`);
|
|
237
334
|
}
|
|
238
335
|
}
|
|
239
336
|
} catch {
|
|
@@ -317,13 +414,13 @@ function createConductorCommands() {
|
|
|
317
414
|
}
|
|
318
415
|
console.log(`Found ${results.length} result(s) for "${query}":
|
|
319
416
|
`);
|
|
320
|
-
for (const
|
|
321
|
-
const date = new Date(
|
|
417
|
+
for (const r2 of results) {
|
|
418
|
+
const date = new Date(r2.captured_at * 1e3).toISOString().slice(0, 16);
|
|
322
419
|
console.log(
|
|
323
|
-
` ${
|
|
420
|
+
` ${r2.issue_id} [${r2.context_type}] attempt ${r2.attempt} (${date})`
|
|
324
421
|
);
|
|
325
|
-
if (
|
|
326
|
-
const snippet =
|
|
422
|
+
if (r2.summary) {
|
|
423
|
+
const snippet = r2.summary.slice(0, 120).replace(/\n/g, " ");
|
|
327
424
|
console.log(` ${snippet}`);
|
|
328
425
|
}
|
|
329
426
|
}
|
|
@@ -354,13 +451,162 @@ function createConductorCommands() {
|
|
|
354
451
|
statuses.sort(
|
|
355
452
|
(a, b) => new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime()
|
|
356
453
|
);
|
|
357
|
-
const
|
|
358
|
-
|
|
454
|
+
const active = statuses.filter((s) => isProcessAlive(s.pid));
|
|
455
|
+
const stalled = statuses.filter(
|
|
456
|
+
(s) => isProcessAlive(s.pid) && Date.now() - new Date(s.lastUpdate).getTime() > 5 * 60 * 1e3
|
|
457
|
+
);
|
|
458
|
+
const dead = statuses.filter((s) => !isProcessAlive(s.pid));
|
|
459
|
+
const healthy = active.length - stalled.length;
|
|
460
|
+
const parts = [];
|
|
461
|
+
if (healthy > 0) parts.push(`${c.green}\u25CF ${healthy}${c.r}`);
|
|
462
|
+
if (stalled.length > 0)
|
|
463
|
+
parts.push(`${c.orange}\u23F8 ${stalled.length}${c.r}`);
|
|
464
|
+
if (dead.length > 0) parts.push(`${c.red}\u2717 ${dead.length}${c.r}`);
|
|
465
|
+
console.log(`
|
|
466
|
+
${c.b}${c.white}Conductor${c.r} ${parts.join(" ")}
|
|
467
|
+
`);
|
|
468
|
+
const cols = (process.stdout.columns || 80) >= 90 ? 2 : 1;
|
|
469
|
+
const rows = [];
|
|
359
470
|
for (const s of statuses) {
|
|
360
471
|
const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
|
|
361
|
-
const
|
|
362
|
-
const
|
|
363
|
-
|
|
472
|
+
const staleFlag = elapsed > 5 * 60 * 1e3;
|
|
473
|
+
const alive = isProcessAlive(s.pid);
|
|
474
|
+
const { icon, color, pct, label } = phaseProgress(
|
|
475
|
+
s.phase,
|
|
476
|
+
s.toolCalls,
|
|
477
|
+
staleFlag,
|
|
478
|
+
alive
|
|
479
|
+
);
|
|
480
|
+
const bar = progressBar(pct, 8);
|
|
481
|
+
const timeColor = !alive ? c.red : staleFlag ? c.orange : c.gray;
|
|
482
|
+
const cell = [
|
|
483
|
+
`${color}${icon}${c.r} ${c.b}${s.issue}${c.r} ${color}${label}${c.r}`,
|
|
484
|
+
` ${bar} ${c.d}${pct}%${c.r} ${c.gray}${s.toolCalls}t ${s.filesModified}f${c.r} ${timeColor}${formatElapsed(elapsed)}${c.r}`
|
|
485
|
+
];
|
|
486
|
+
rows.push(cell);
|
|
487
|
+
}
|
|
488
|
+
if (cols === 2) {
|
|
489
|
+
for (let i = 0; i < rows.length; i += 2) {
|
|
490
|
+
const left = rows[i];
|
|
491
|
+
const right = rows[i + 1];
|
|
492
|
+
const pad = 42;
|
|
493
|
+
if (right) {
|
|
494
|
+
console.log(
|
|
495
|
+
` ${left[0].padEnd(pad + 30)}${c.gray}\u2502${c.r} ${right[0]}`
|
|
496
|
+
);
|
|
497
|
+
console.log(
|
|
498
|
+
` ${left[1].padEnd(pad + 30)}${c.gray}\u2502${c.r} ${right[1]}`
|
|
499
|
+
);
|
|
500
|
+
} else {
|
|
501
|
+
console.log(` ${left[0]}`);
|
|
502
|
+
console.log(` ${left[1]}`);
|
|
503
|
+
}
|
|
504
|
+
if (i + 2 < rows.length) {
|
|
505
|
+
console.log(` ${c.gray}${"\u254C".repeat(38)}\u253C${"\u254C".repeat(38)}${c.r}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
} else {
|
|
509
|
+
for (let i = 0; i < rows.length; i++) {
|
|
510
|
+
console.log(` ${rows[i][0]}`);
|
|
511
|
+
console.log(` ${rows[i][1]}`);
|
|
512
|
+
if (i < rows.length - 1) {
|
|
513
|
+
console.log(` ${c.gray}${"\u254C".repeat(38)}${c.r}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
console.log("");
|
|
518
|
+
});
|
|
519
|
+
cmd.command("finalize").description("Clean up completed/dead agents that conductor missed").option("--dry-run", "Show what would be done without doing it", false).action(async (options) => {
|
|
520
|
+
const agentsDir = join(homedir(), ".stackmemory", "conductor", "agents");
|
|
521
|
+
if (!existsSync(agentsDir)) {
|
|
522
|
+
console.log("No agent status files found");
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const entries = readdirSync(agentsDir, { withFileTypes: true });
|
|
526
|
+
const statuses = [];
|
|
527
|
+
for (const entry of entries) {
|
|
528
|
+
if (!entry.isDirectory()) continue;
|
|
529
|
+
const statusPath = join(agentsDir, entry.name, "status.json");
|
|
530
|
+
if (!existsSync(statusPath)) continue;
|
|
531
|
+
try {
|
|
532
|
+
const data = JSON.parse(readFileSync(statusPath, "utf-8"));
|
|
533
|
+
statuses.push({ ...data, dir: entry.name });
|
|
534
|
+
} catch {
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const needsFinalize = statuses.filter((s) => {
|
|
538
|
+
const alive = isProcessAlive(s.pid);
|
|
539
|
+
const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
|
|
540
|
+
return !alive || elapsed > 60 * 60 * 1e3;
|
|
541
|
+
});
|
|
542
|
+
if (needsFinalize.length === 0) {
|
|
543
|
+
console.log(
|
|
544
|
+
`${c.green}All agents are healthy \u2014 nothing to finalize.${c.r}`
|
|
545
|
+
);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
console.log(
|
|
549
|
+
`
|
|
550
|
+
${c.b}Finalizing ${needsFinalize.length} agent(s)${c.r}
|
|
551
|
+
`
|
|
552
|
+
);
|
|
553
|
+
for (const s of needsFinalize) {
|
|
554
|
+
const alive = isProcessAlive(s.pid);
|
|
555
|
+
const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
|
|
556
|
+
const elapsedStr = formatElapsed(elapsed).replace(" ago", "");
|
|
557
|
+
let hasCommits = false;
|
|
558
|
+
if (s.workspacePath && existsSync(s.workspacePath)) {
|
|
559
|
+
try {
|
|
560
|
+
const log = execSync("git log origin/main..HEAD --oneline", {
|
|
561
|
+
cwd: s.workspacePath,
|
|
562
|
+
encoding: "utf-8",
|
|
563
|
+
timeout: 1e4
|
|
564
|
+
});
|
|
565
|
+
hasCommits = log.trim().length > 0;
|
|
566
|
+
} catch {
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const statusIcon = !alive ? `${c.red}\u2717 dead${c.r}` : `${c.orange}\u23F8 stalled ${elapsedStr}${c.r}`;
|
|
570
|
+
const commitStatus = hasCommits ? `${c.green}has commits \u2192 In Review${c.r}` : `${c.gray}no commits \u2192 mark failed${c.r}`;
|
|
571
|
+
console.log(` ${c.b}${s.issue}${c.r} ${statusIcon} ${commitStatus}`);
|
|
572
|
+
if (options.dryRun) continue;
|
|
573
|
+
if (alive) {
|
|
574
|
+
try {
|
|
575
|
+
process.kill(s.pid, "SIGTERM");
|
|
576
|
+
console.log(` ${c.gray}Sent SIGTERM to pid ${s.pid}${c.r}`);
|
|
577
|
+
} catch {
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const statusPath = join(agentsDir, s.dir, "status.json");
|
|
581
|
+
try {
|
|
582
|
+
const updated = { ...s };
|
|
583
|
+
delete updated["dir"];
|
|
584
|
+
writeFileSync(
|
|
585
|
+
statusPath,
|
|
586
|
+
JSON.stringify(
|
|
587
|
+
{ ...updated, lastUpdate: (/* @__PURE__ */ new Date()).toISOString() },
|
|
588
|
+
null,
|
|
589
|
+
2
|
|
590
|
+
)
|
|
591
|
+
);
|
|
592
|
+
} catch {
|
|
593
|
+
}
|
|
594
|
+
if (hasCommits) {
|
|
595
|
+
console.log(
|
|
596
|
+
` ${c.cyan}\u2192 Move ${s.issue} to "In Review" in Linear${c.r}`
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (options.dryRun) {
|
|
601
|
+
console.log(
|
|
602
|
+
`
|
|
603
|
+
${c.d}Dry run \u2014 no changes made. Remove --dry-run to execute.${c.r}`
|
|
604
|
+
);
|
|
605
|
+
} else {
|
|
606
|
+
console.log(
|
|
607
|
+
`
|
|
608
|
+
${c.green}Done.${c.r} Run ${c.cyan}conductor status${c.r} to verify.`
|
|
609
|
+
);
|
|
364
610
|
}
|
|
365
611
|
});
|
|
366
612
|
cmd.command("logs").description("Tail agent output log").argument("<issue-id>", "Issue identifier (e.g., STA-499)").option("-f, --follow", "Follow the log (tail -f)", false).option("-n, --lines <n>", "Number of lines to show", "50").action(async (issueId, options) => {
|
|
@@ -474,13 +720,6 @@ function createConductorCommands() {
|
|
|
474
720
|
let paused = false;
|
|
475
721
|
let refreshInterval = interval;
|
|
476
722
|
let phaseFilter = options.phase || null;
|
|
477
|
-
const b = "\x1B[1m";
|
|
478
|
-
const d = "\x1B[2m";
|
|
479
|
-
const cyan = "\x1B[36m";
|
|
480
|
-
const green = "\x1B[32m";
|
|
481
|
-
const yellow = "\x1B[33m";
|
|
482
|
-
const red = "\x1B[31m";
|
|
483
|
-
const r = "\x1B[0m";
|
|
484
723
|
function readStatuses() {
|
|
485
724
|
const agentsDir = join(
|
|
486
725
|
homedir(),
|
|
@@ -501,7 +740,7 @@ function createConductorCommands() {
|
|
|
501
740
|
}
|
|
502
741
|
}
|
|
503
742
|
statuses.sort(
|
|
504
|
-
(a,
|
|
743
|
+
(a, b) => new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime()
|
|
505
744
|
);
|
|
506
745
|
if (phaseFilter) {
|
|
507
746
|
return statuses.filter((s) => s.phase === phaseFilter);
|
|
@@ -514,12 +753,19 @@ function createConductorCommands() {
|
|
|
514
753
|
console.log(` No active agents${filterNote}`);
|
|
515
754
|
return;
|
|
516
755
|
}
|
|
517
|
-
const header = `${"Issue".padEnd(12)}${"Phase".padEnd(16)}${"Tools".padStart(7)}${"Files".padStart(7)}${"Tokens".padStart(9)} Last Update`;
|
|
518
|
-
console.log(header);
|
|
519
756
|
for (const s of statuses) {
|
|
520
757
|
const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
|
|
521
|
-
const
|
|
522
|
-
|
|
758
|
+
const stale = elapsed > 5 * 60 * 1e3;
|
|
759
|
+
const alive = isProcessAlive(s.pid);
|
|
760
|
+
const prog = phaseProgress(s.phase, s.toolCalls, stale, alive);
|
|
761
|
+
const bar = progressBar(prog.pct, 10);
|
|
762
|
+
const timeColor = !alive ? c.red : stale ? c.orange : c.gray;
|
|
763
|
+
console.log(
|
|
764
|
+
` ${prog.color}${prog.icon}${c.r} ${c.b}${s.issue}${c.r} ${prog.color}${prog.label}${c.r} ${timeColor}${formatElapsed(elapsed)}${c.r}`
|
|
765
|
+
);
|
|
766
|
+
console.log(
|
|
767
|
+
` ${bar} ${c.d}${prog.pct}%${c.r} ${c.gray}${s.toolCalls} tools ${s.filesModified} files ${fmtTokens(s.tokensUsed)} tok${c.r}`
|
|
768
|
+
);
|
|
523
769
|
}
|
|
524
770
|
}
|
|
525
771
|
function getWorktreeFiles(workspacePath) {
|
|
@@ -543,23 +789,25 @@ function createConductorCommands() {
|
|
|
543
789
|
}
|
|
544
790
|
for (const s of statuses) {
|
|
545
791
|
const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
|
|
546
|
-
const
|
|
792
|
+
const stale = elapsed > 5 * 60 * 1e3;
|
|
793
|
+
const alive = isProcessAlive(s.pid);
|
|
794
|
+
const prog = phaseProgress(s.phase, s.toolCalls, stale, alive);
|
|
547
795
|
console.log(
|
|
548
|
-
|
|
796
|
+
` ${prog.color}${prog.icon}${c.r} ${c.b}${s.issue}${c.r} ${prog.color}${prog.label}${c.r} ${c.gray}${formatElapsed(elapsed)}${c.r}`
|
|
549
797
|
);
|
|
550
798
|
const files = getWorktreeFiles(s.workspacePath || "");
|
|
551
799
|
if (files.length === 0) {
|
|
552
|
-
console.log(`
|
|
800
|
+
console.log(` ${c.d}(no file changes)${c.r}`);
|
|
553
801
|
} else {
|
|
554
802
|
for (const f of files) {
|
|
555
803
|
const status = f.substring(0, 2);
|
|
556
804
|
const path = f.substring(3);
|
|
557
|
-
let
|
|
558
|
-
if (status.includes("M"))
|
|
805
|
+
let col = "";
|
|
806
|
+
if (status.includes("M")) col = c.yellow;
|
|
559
807
|
else if (status.includes("A") || status.includes("?"))
|
|
560
|
-
|
|
561
|
-
else if (status.includes("D"))
|
|
562
|
-
console.log(`
|
|
808
|
+
col = c.green;
|
|
809
|
+
else if (status.includes("D")) col = c.red;
|
|
810
|
+
console.log(` ${col}${status}${c.r} ${path}`);
|
|
563
811
|
}
|
|
564
812
|
}
|
|
565
813
|
console.log("");
|
|
@@ -575,23 +823,20 @@ function createConductorCommands() {
|
|
|
575
823
|
const pauseTag = paused ? " [PAUSED]" : "";
|
|
576
824
|
const intervalSec = Math.round(refreshInterval / 1e3);
|
|
577
825
|
console.log(
|
|
578
|
-
`${b}\
|
|
579
|
-
);
|
|
580
|
-
console.log(
|
|
581
|
-
`${b} Conductor Monitor${r} ${(/* @__PURE__ */ new Date()).toLocaleTimeString()}${pauseTag}`
|
|
826
|
+
`${c.purple}${c.b} \u2501\u2501\u2501 Conductor Monitor \u2501\u2501\u2501${c.r} ${c.gray}${(/* @__PURE__ */ new Date()).toLocaleTimeString()}${pauseTag}${c.r}`
|
|
582
827
|
);
|
|
583
828
|
console.log(
|
|
584
|
-
` Mode
|
|
829
|
+
` ${c.gray}Mode:${c.r} ${c.cyan}${currentMode}${c.r} ${c.gray}\u2502${c.r} ${c.gray}Refresh:${c.r} ${intervalSec}s`
|
|
585
830
|
);
|
|
586
|
-
const filterNote = phaseFilter ? ` Filter
|
|
831
|
+
const filterNote = phaseFilter ? ` ${c.gray}Filter:${c.r} ${c.cyan}${phaseFilter}${c.r}` : "";
|
|
587
832
|
if (interactive) {
|
|
588
833
|
console.log(
|
|
589
|
-
` ${d}[s]tatus [u]sage [f]iles [d]ashboard [j]son [l]ogs [r]efresh [p]ause [1-5]phase [0]clear [+/-] [q]uit${r}`
|
|
834
|
+
` ${c.d}[s]tatus [u]sage [f]iles [d]ashboard [j]son [l]ogs [r]efresh [p]ause [1-5]phase [0]clear [+/-] [q]uit${c.r}`
|
|
590
835
|
);
|
|
591
836
|
}
|
|
592
837
|
if (filterNote) console.log(filterNote);
|
|
593
838
|
console.log(
|
|
594
|
-
`${
|
|
839
|
+
`${c.gray} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${c.r}`
|
|
595
840
|
);
|
|
596
841
|
console.log("");
|
|
597
842
|
const statuses = readStatuses();
|
|
@@ -6,6 +6,7 @@ import { spawn, execSync } from "child_process";
|
|
|
6
6
|
import {
|
|
7
7
|
existsSync,
|
|
8
8
|
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
9
10
|
rmSync,
|
|
10
11
|
writeFileSync,
|
|
11
12
|
readdirSync,
|
|
@@ -452,6 +453,7 @@ class Conductor {
|
|
|
452
453
|
logger.info("Rate limit backoff expired, resuming dispatch");
|
|
453
454
|
console.log("[rate-limit] Backoff expired, resuming dispatch");
|
|
454
455
|
}
|
|
456
|
+
await this.checkStaleAgents();
|
|
455
457
|
await this.reconcile();
|
|
456
458
|
const available = this.config.maxConcurrent - this.running.size;
|
|
457
459
|
if (available <= 0) {
|
|
@@ -949,6 +951,26 @@ class Conductor {
|
|
|
949
951
|
return "claude";
|
|
950
952
|
}
|
|
951
953
|
// ── Agent Execution ──
|
|
954
|
+
/**
|
|
955
|
+
* Build environment variables for an agent process.
|
|
956
|
+
* Injects PORTLESS_URL if portless is available, giving each worktree
|
|
957
|
+
* a stable named localhost URL for service discovery.
|
|
958
|
+
*/
|
|
959
|
+
buildAgentEnv(issue, run) {
|
|
960
|
+
const env = { ...process.env };
|
|
961
|
+
delete env["CLAUDECODE"];
|
|
962
|
+
delete env["ANTHROPIC_API_KEY"];
|
|
963
|
+
const wsKey = this.sanitizeIdentifier(issue.identifier);
|
|
964
|
+
return {
|
|
965
|
+
...env,
|
|
966
|
+
SYMPHONY_WORKSPACE_DIR: run.workspacePath,
|
|
967
|
+
SYMPHONY_ISSUE_ID: issue.id,
|
|
968
|
+
SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
|
|
969
|
+
SYMPHONY_ATTEMPT: String(run.attempt),
|
|
970
|
+
PORTLESS_URL: `http://${wsKey}.localhost:1355`,
|
|
971
|
+
PORTLESS_NAME: wsKey
|
|
972
|
+
};
|
|
973
|
+
}
|
|
952
974
|
async runAgent(issue, run) {
|
|
953
975
|
if (this.config.agentMode === "cli") {
|
|
954
976
|
try {
|
|
@@ -995,18 +1017,7 @@ class Conductor {
|
|
|
995
1017
|
],
|
|
996
1018
|
{
|
|
997
1019
|
cwd: run.workspacePath,
|
|
998
|
-
env: (
|
|
999
|
-
const env = { ...process.env };
|
|
1000
|
-
delete env.CLAUDECODE;
|
|
1001
|
-
delete env.ANTHROPIC_API_KEY;
|
|
1002
|
-
return {
|
|
1003
|
-
...env,
|
|
1004
|
-
SYMPHONY_WORKSPACE_DIR: run.workspacePath,
|
|
1005
|
-
SYMPHONY_ISSUE_ID: issue.id,
|
|
1006
|
-
SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
|
|
1007
|
-
SYMPHONY_ATTEMPT: String(run.attempt)
|
|
1008
|
-
};
|
|
1009
|
-
})(),
|
|
1020
|
+
env: this.buildAgentEnv(issue, run),
|
|
1010
1021
|
stdio: ["pipe", "pipe", "pipe"]
|
|
1011
1022
|
}
|
|
1012
1023
|
);
|
|
@@ -1114,18 +1125,9 @@ class Conductor {
|
|
|
1114
1125
|
runAgentAdapter(issue, run) {
|
|
1115
1126
|
return new Promise((resolve, reject) => {
|
|
1116
1127
|
const prompt = this.buildPrompt(issue, run.attempt);
|
|
1117
|
-
const env = { ...process.env };
|
|
1118
|
-
delete env.CLAUDECODE;
|
|
1119
|
-
delete env.ANTHROPIC_API_KEY;
|
|
1120
1128
|
const proc = spawn("node", [this.config.appServerPath], {
|
|
1121
1129
|
cwd: run.workspacePath,
|
|
1122
|
-
env:
|
|
1123
|
-
...env,
|
|
1124
|
-
SYMPHONY_WORKSPACE_DIR: run.workspacePath,
|
|
1125
|
-
SYMPHONY_ISSUE_ID: issue.id,
|
|
1126
|
-
SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
|
|
1127
|
-
SYMPHONY_ATTEMPT: String(run.attempt)
|
|
1128
|
-
},
|
|
1130
|
+
env: this.buildAgentEnv(issue, run),
|
|
1129
1131
|
stdio: ["pipe", "pipe", "pipe"]
|
|
1130
1132
|
});
|
|
1131
1133
|
run.process = proc;
|
|
@@ -1405,6 +1407,117 @@ class Conductor {
|
|
|
1405
1407
|
}
|
|
1406
1408
|
}
|
|
1407
1409
|
// ── Reconciliation ──
|
|
1410
|
+
/**
|
|
1411
|
+
* Check for stale agents — no status update in 1 hour.
|
|
1412
|
+
* If the process is dead, clean up. If alive but stuck, kill and free the slot.
|
|
1413
|
+
*/
|
|
1414
|
+
async checkStaleAgents() {
|
|
1415
|
+
if (this.running.size === 0) return;
|
|
1416
|
+
const staleThresholdMs = 60 * 60 * 1e3;
|
|
1417
|
+
const now = Date.now();
|
|
1418
|
+
for (const [issueId, run] of this.running) {
|
|
1419
|
+
const agentDir = getAgentStatusDir(run.issue.identifier);
|
|
1420
|
+
const statusPath = join(agentDir, "status.json");
|
|
1421
|
+
let lastUpdate = run.startedAt;
|
|
1422
|
+
try {
|
|
1423
|
+
const data = JSON.parse(readFileSync(statusPath, "utf-8"));
|
|
1424
|
+
lastUpdate = new Date(data.lastUpdate).getTime();
|
|
1425
|
+
} catch {
|
|
1426
|
+
}
|
|
1427
|
+
const elapsed = now - lastUpdate;
|
|
1428
|
+
if (elapsed < staleThresholdMs) continue;
|
|
1429
|
+
const elapsedMin = Math.round(elapsed / 6e4);
|
|
1430
|
+
const pid = run.process?.pid;
|
|
1431
|
+
let alive = false;
|
|
1432
|
+
if (pid) {
|
|
1433
|
+
try {
|
|
1434
|
+
process.kill(pid, 0);
|
|
1435
|
+
alive = true;
|
|
1436
|
+
} catch {
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
if (!alive) {
|
|
1440
|
+
logger.warn("Stale agent process is dead, cleaning up", {
|
|
1441
|
+
identifier: run.issue.identifier,
|
|
1442
|
+
pid,
|
|
1443
|
+
staleMins: elapsedMin
|
|
1444
|
+
});
|
|
1445
|
+
console.log(
|
|
1446
|
+
`[${run.issue.identifier}] Agent dead after ${elapsedMin}m \u2014 finalizing`
|
|
1447
|
+
);
|
|
1448
|
+
await this.finalizeStaleAgent(run);
|
|
1449
|
+
this.running.delete(issueId);
|
|
1450
|
+
} else {
|
|
1451
|
+
logger.warn("Stale agent still alive but stuck, killing", {
|
|
1452
|
+
identifier: run.issue.identifier,
|
|
1453
|
+
pid,
|
|
1454
|
+
staleMins: elapsedMin
|
|
1455
|
+
});
|
|
1456
|
+
console.log(
|
|
1457
|
+
`[${run.issue.identifier}] Agent stuck for ${elapsedMin}m \u2014 killing pid ${pid}`
|
|
1458
|
+
);
|
|
1459
|
+
run.process?.kill("SIGTERM");
|
|
1460
|
+
await new Promise((resolve) => setTimeout(resolve, 1e4));
|
|
1461
|
+
if (run.process && !run.process.killed) {
|
|
1462
|
+
run.process.kill("SIGKILL");
|
|
1463
|
+
}
|
|
1464
|
+
await this.finalizeStaleAgent(run);
|
|
1465
|
+
this.running.delete(issueId);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Finalize an agent that completed or died without proper cleanup.
|
|
1471
|
+
* Checks for commits, transitions Linear issue, takes snapshot.
|
|
1472
|
+
*/
|
|
1473
|
+
async finalizeStaleAgent(run) {
|
|
1474
|
+
const wsPath = run.workspacePath;
|
|
1475
|
+
if (!wsPath || !existsSync(wsPath)) {
|
|
1476
|
+
this.completed.add(run.issue.id);
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
let hasCommits = false;
|
|
1480
|
+
try {
|
|
1481
|
+
const log = execSync(
|
|
1482
|
+
`git log origin/${this.config.baseBranch}..HEAD --oneline`,
|
|
1483
|
+
{ cwd: wsPath, encoding: "utf-8", timeout: 1e4 }
|
|
1484
|
+
);
|
|
1485
|
+
hasCommits = log.trim().length > 0;
|
|
1486
|
+
} catch {
|
|
1487
|
+
}
|
|
1488
|
+
if (hasCommits) {
|
|
1489
|
+
logger.info("Stale agent had commits, transitioning to In Review", {
|
|
1490
|
+
identifier: run.issue.identifier
|
|
1491
|
+
});
|
|
1492
|
+
console.log(
|
|
1493
|
+
`[${run.issue.identifier}] Found commits \u2014 moving to In Review`
|
|
1494
|
+
);
|
|
1495
|
+
this.takeSnapshot(wsPath, run.issue);
|
|
1496
|
+
await this.transitionIssue(run.issue, this.config.inReviewState).catch(
|
|
1497
|
+
(err) => {
|
|
1498
|
+
logger.error("Failed to transition stale agent issue", {
|
|
1499
|
+
identifier: run.issue.identifier,
|
|
1500
|
+
error: err.message
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
);
|
|
1504
|
+
this.completeCount++;
|
|
1505
|
+
} else {
|
|
1506
|
+
logger.info("Stale agent had no commits, marking as failed", {
|
|
1507
|
+
identifier: run.issue.identifier
|
|
1508
|
+
});
|
|
1509
|
+
console.log(
|
|
1510
|
+
`[${run.issue.identifier}] No commits found \u2014 marking failed`
|
|
1511
|
+
);
|
|
1512
|
+
this.failCount++;
|
|
1513
|
+
}
|
|
1514
|
+
this.writeAgentStatus(run.issue.identifier, {
|
|
1515
|
+
...run,
|
|
1516
|
+
status: hasCommits ? "completed" : "failed",
|
|
1517
|
+
phase: hasCommits ? "committing" : run.phase
|
|
1518
|
+
});
|
|
1519
|
+
this.completed.add(run.issue.id);
|
|
1520
|
+
}
|
|
1408
1521
|
async reconcile() {
|
|
1409
1522
|
if (!this.client || this.running.size === 0) return;
|
|
1410
1523
|
for (const [issueId, run] of this.running) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stackmemoryai/stackmemory",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.8",
|
|
4
4
|
"description": "Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, and automatic hooks.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0",
|