@stackmemoryai/stackmemory 1.5.2 → 1.5.3
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.
package/README.md
CHANGED
|
@@ -24,7 +24,8 @@ StackMemory is a **production-ready memory runtime** for AI coding tools that pr
|
|
|
24
24
|
- **Automatic hooks** for task tracking, Linear sync, and spec progress
|
|
25
25
|
- **Snapshot capture** — post-run context snapshots for session handoff (`snapshot save`)
|
|
26
26
|
- **Pre-flight overlap check** — predict file conflicts before parallel task dispatch (`preflight`)
|
|
27
|
-
- **Conductor orchestrator** — polls Linear, creates worktrees, spawns agents with bounded concurrency
|
|
27
|
+
- **Conductor orchestrator** — polls Linear, creates worktrees, spawns agents with bounded concurrency and auto team detection
|
|
28
|
+
- **Loop/Watch command** — poll any shell command until a condition is met (monitor CI, deploys, logs)
|
|
28
29
|
- **Memory monitor daemon** with automatic capture/clear on RAM pressure
|
|
29
30
|
- **Auto-save service** for periodic context persistence
|
|
30
31
|
- **Comprehensive test coverage** across all core modules
|
|
@@ -68,6 +69,8 @@ Tools forget decisions and constraints between sessions. StackMemory makes conte
|
|
|
68
69
|
- **Safe branches**: worktree isolation with `--worktree` or `-w`
|
|
69
70
|
- **Snapshot**: post-run context capture — records what changed, commits, and decisions (`stackmemory snapshot`)
|
|
70
71
|
- **Pre-flight check**: file overlap prediction before parallel task dispatch (`stackmemory preflight`)
|
|
72
|
+
- **Loop/Watch**: poll shell commands until conditions are met — monitor CI, deploys, logs (`stackmemory loop`)
|
|
73
|
+
- **Conductor**: autonomous orchestrator — polls Linear, creates worktrees, spawns agents with auto team detection
|
|
71
74
|
- **Persistent context**: frames, anchors, decisions, retrieval
|
|
72
75
|
- **Integrations**: Linear (API key + OAuth), DiffMem, Browser MCP, log-mcp (log analysis)
|
|
73
76
|
|
|
@@ -389,6 +392,26 @@ stackmemory pf "auth work" "db migration" -f "task1:src/auth.ts;task2:src/db.ts"
|
|
|
389
392
|
stackmemory pf "task A" "task B" "task C" --json
|
|
390
393
|
```
|
|
391
394
|
|
|
395
|
+
### Loop / Watch (`loop` / `watch`)
|
|
396
|
+
|
|
397
|
+
Poll a shell command until a condition is met. Useful for monitoring CI runs, deploy logs, inboxes, or any external state.
|
|
398
|
+
|
|
399
|
+
```bash
|
|
400
|
+
# Monitor GitHub Actions until complete
|
|
401
|
+
stackmemory loop "gh run view <id> --json status -q .status" --until "completed"
|
|
402
|
+
|
|
403
|
+
# Watch deploy logs for success
|
|
404
|
+
stackmemory watch "railway logs --latest" --until "deployed" -i 5s
|
|
405
|
+
|
|
406
|
+
# Wait for health check to pass
|
|
407
|
+
stackmemory loop "curl -s http://localhost:3000/health" --until "ok" -i 3s -t 5m
|
|
408
|
+
|
|
409
|
+
# JSON output for programmatic use
|
|
410
|
+
stackmemory loop "gh pr checks 42 --json state -q '.[0].state'" --until "SUCCESS" --json
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Options: `--until`, `--until-not`, `--until-empty`, `--until-non-empty`, `--until-exit`, `-i/--interval` (default 10s), `-t/--timeout` (default 30m), `--json`, `-q/--quiet`
|
|
414
|
+
|
|
392
415
|
---
|
|
393
416
|
|
|
394
417
|
## Documentation
|
|
@@ -8,7 +8,7 @@ import Table from "cli-table3";
|
|
|
8
8
|
import { SessionManager } from "../../core/session/session-manager.js";
|
|
9
9
|
import Database from "better-sqlite3";
|
|
10
10
|
import { join } from "path";
|
|
11
|
-
import { existsSync } from "fs";
|
|
11
|
+
import { existsSync, readFileSync } from "fs";
|
|
12
12
|
import { getModelTokenLimit } from "../../core/models/model-router.js";
|
|
13
13
|
const dashboardCommand = {
|
|
14
14
|
command: "dashboard",
|
|
@@ -150,6 +150,52 @@ const dashboardCommand = {
|
|
|
150
150
|
console.log(chalk.yellow.bold("\u{1F4BE} Context Usage"));
|
|
151
151
|
console.log(`${usageBar} ${contextUsage}%`);
|
|
152
152
|
console.log();
|
|
153
|
+
const conductorStatusPath = join(
|
|
154
|
+
projectRoot,
|
|
155
|
+
".stackmemory",
|
|
156
|
+
"conductor-status.json"
|
|
157
|
+
);
|
|
158
|
+
if (existsSync(conductorStatusPath)) {
|
|
159
|
+
try {
|
|
160
|
+
const raw = readFileSync(conductorStatusPath, "utf-8");
|
|
161
|
+
const conductorStatus = JSON.parse(raw);
|
|
162
|
+
const stale = Date.now() - conductorStatus.updatedAt > 12e4;
|
|
163
|
+
const header = stale ? chalk.gray.bold("\u2699 Conductor (stale)") : chalk.yellow.bold("\u2699 Conductor");
|
|
164
|
+
console.log(header);
|
|
165
|
+
if (conductorStatus.running.length > 0) {
|
|
166
|
+
const conductorTable = new Table({
|
|
167
|
+
head: [
|
|
168
|
+
chalk.white("Issue"),
|
|
169
|
+
chalk.white("Status"),
|
|
170
|
+
chalk.white("Title"),
|
|
171
|
+
chalk.white("Runtime")
|
|
172
|
+
],
|
|
173
|
+
style: { head: [], border: [] },
|
|
174
|
+
colWidths: [12, 14, 36, 10]
|
|
175
|
+
});
|
|
176
|
+
conductorStatus.running.forEach((r) => {
|
|
177
|
+
const mins = Math.round(r.runtime / 6e4);
|
|
178
|
+
const statusColor = r.status === "running" ? chalk.green : r.status === "completed" ? chalk.cyan : chalk.red;
|
|
179
|
+
conductorTable.push([
|
|
180
|
+
r.identifier,
|
|
181
|
+
statusColor(r.status),
|
|
182
|
+
r.title.slice(0, 34),
|
|
183
|
+
`${mins}m`
|
|
184
|
+
]);
|
|
185
|
+
});
|
|
186
|
+
console.log(conductorTable.toString());
|
|
187
|
+
} else {
|
|
188
|
+
console.log(chalk.gray(" No agents running"));
|
|
189
|
+
}
|
|
190
|
+
console.log(
|
|
191
|
+
chalk.gray(
|
|
192
|
+
` Completed: ${conductorStatus.completed} Failed: ${conductorStatus.failed} Max: ${conductorStatus.maxConcurrent}`
|
|
193
|
+
)
|
|
194
|
+
);
|
|
195
|
+
console.log();
|
|
196
|
+
} catch {
|
|
197
|
+
}
|
|
198
|
+
}
|
|
153
199
|
db.close();
|
|
154
200
|
if (argv.watch) {
|
|
155
201
|
console.log(
|
|
@@ -3,13 +3,20 @@ import { dirname as __pathDirname } from 'path';
|
|
|
3
3
|
const __filename = __fileURLToPath(import.meta.url);
|
|
4
4
|
const __dirname = __pathDirname(__filename);
|
|
5
5
|
import { Command } from "commander";
|
|
6
|
-
import { execSync } from "child_process";
|
|
7
|
-
import {
|
|
6
|
+
import { execSync, spawn as cpSpawn } from "child_process";
|
|
7
|
+
import {
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
readdirSync
|
|
13
|
+
} from "fs";
|
|
8
14
|
import { join } from "path";
|
|
9
15
|
import { homedir, tmpdir } from "os";
|
|
10
16
|
import Database from "better-sqlite3";
|
|
11
17
|
import { logger } from "../../core/monitoring/logger.js";
|
|
12
18
|
import { Conductor } from "./orchestrator.js";
|
|
19
|
+
import { getAgentStatusDir } from "./orchestrator.js";
|
|
13
20
|
function getGlobalStorePath() {
|
|
14
21
|
const dir = join(homedir(), ".stackmemory", "conductor");
|
|
15
22
|
if (!existsSync(dir)) {
|
|
@@ -43,6 +50,16 @@ function getGlobalDb() {
|
|
|
43
50
|
`);
|
|
44
51
|
return db;
|
|
45
52
|
}
|
|
53
|
+
function formatElapsed(ms) {
|
|
54
|
+
const seconds = Math.floor(ms / 1e3);
|
|
55
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
56
|
+
const minutes = Math.floor(seconds / 60);
|
|
57
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
58
|
+
const hours = Math.floor(minutes / 60);
|
|
59
|
+
if (hours < 24) return `${hours}h ago`;
|
|
60
|
+
const days = Math.floor(hours / 24);
|
|
61
|
+
return `${days}d ago`;
|
|
62
|
+
}
|
|
46
63
|
function createConductorCommands() {
|
|
47
64
|
const cmd = new Command("conductor");
|
|
48
65
|
cmd.description("Conductor \u2014 autonomous agent orchestration via Linear");
|
|
@@ -263,6 +280,60 @@ function createConductorCommands() {
|
|
|
263
280
|
}
|
|
264
281
|
globalDb.close();
|
|
265
282
|
});
|
|
283
|
+
cmd.command("status").description("Show running agent status table").action(async () => {
|
|
284
|
+
const agentsDir = join(homedir(), ".stackmemory", "conductor", "agents");
|
|
285
|
+
if (!existsSync(agentsDir)) {
|
|
286
|
+
console.log("No agent status files found");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const entries = readdirSync(agentsDir, { withFileTypes: true });
|
|
290
|
+
const statuses = [];
|
|
291
|
+
for (const entry of entries) {
|
|
292
|
+
if (!entry.isDirectory()) continue;
|
|
293
|
+
const statusPath = join(agentsDir, entry.name, "status.json");
|
|
294
|
+
if (!existsSync(statusPath)) continue;
|
|
295
|
+
try {
|
|
296
|
+
const data = JSON.parse(readFileSync(statusPath, "utf-8"));
|
|
297
|
+
statuses.push(data);
|
|
298
|
+
} catch {
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (statuses.length === 0) {
|
|
302
|
+
console.log("No agent status files found");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
statuses.sort(
|
|
306
|
+
(a, b) => new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime()
|
|
307
|
+
);
|
|
308
|
+
const header = `${"Issue".padEnd(12)}${"Phase".padEnd(16)}${"Tools".padStart(7)}${"Files".padStart(7)}${"Tokens".padStart(9)} Last Update`;
|
|
309
|
+
console.log(header);
|
|
310
|
+
for (const s of statuses) {
|
|
311
|
+
const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
|
|
312
|
+
const lastUpdate = formatElapsed(elapsed);
|
|
313
|
+
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}`;
|
|
314
|
+
console.log(line);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
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) => {
|
|
318
|
+
const logPath = join(getAgentStatusDir(issueId), "output.log");
|
|
319
|
+
if (!existsSync(logPath)) {
|
|
320
|
+
console.error(`No log file found for ${issueId} at ${logPath}`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const lines = parseInt(options.lines, 10);
|
|
324
|
+
const args = options.follow ? ["-f", "-n", String(lines), logPath] : ["-n", String(lines), logPath];
|
|
325
|
+
const tail = cpSpawn("tail", args, { stdio: "inherit" });
|
|
326
|
+
await new Promise((resolve) => {
|
|
327
|
+
tail.on("close", () => {
|
|
328
|
+
resolve();
|
|
329
|
+
});
|
|
330
|
+
const forward = () => {
|
|
331
|
+
tail.kill("SIGTERM");
|
|
332
|
+
};
|
|
333
|
+
process.on("SIGINT", forward);
|
|
334
|
+
process.on("SIGTERM", forward);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
266
337
|
cmd.command("start").description("Start the orchestrator daemon").option("--team <id>", "Linear team ID").option(
|
|
267
338
|
"--states <states>",
|
|
268
339
|
"Comma-separated issue states to pick up",
|
|
@@ -294,5 +365,6 @@ function createConductorCommands() {
|
|
|
294
365
|
return cmd;
|
|
295
366
|
}
|
|
296
367
|
export {
|
|
297
|
-
createConductorCommands
|
|
368
|
+
createConductorCommands,
|
|
369
|
+
formatElapsed
|
|
298
370
|
};
|
|
@@ -3,10 +3,17 @@ import { dirname as __pathDirname } from 'path';
|
|
|
3
3
|
const __filename = __fileURLToPath(import.meta.url);
|
|
4
4
|
const __dirname = __pathDirname(__filename);
|
|
5
5
|
import { spawn, execSync } from "child_process";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
createWriteStream
|
|
12
|
+
} from "fs";
|
|
7
13
|
import { join, dirname } from "path";
|
|
8
|
-
import { tmpdir } from "os";
|
|
14
|
+
import { tmpdir, homedir } from "os";
|
|
9
15
|
import { fileURLToPath } from "url";
|
|
16
|
+
import { Transform } from "stream";
|
|
10
17
|
import { logger } from "../../core/monitoring/logger.js";
|
|
11
18
|
import {
|
|
12
19
|
LinearClient
|
|
@@ -26,6 +33,65 @@ function findPackageRoot() {
|
|
|
26
33
|
}
|
|
27
34
|
return dirname(currentFile);
|
|
28
35
|
}
|
|
36
|
+
function getAgentStatusDir(issueIdentifier) {
|
|
37
|
+
return join(
|
|
38
|
+
homedir(),
|
|
39
|
+
".stackmemory",
|
|
40
|
+
"conductor",
|
|
41
|
+
"agents",
|
|
42
|
+
issueIdentifier
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
function ensureAgentStatusDir(issueIdentifier) {
|
|
46
|
+
const dir = getAgentStatusDir(issueIdentifier);
|
|
47
|
+
if (!existsSync(dir)) {
|
|
48
|
+
mkdirSync(dir, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
return dir;
|
|
51
|
+
}
|
|
52
|
+
class TeeTransform extends Transform {
|
|
53
|
+
logStream;
|
|
54
|
+
constructor(logStream) {
|
|
55
|
+
super();
|
|
56
|
+
this.logStream = logStream;
|
|
57
|
+
}
|
|
58
|
+
_transform(chunk, _encoding, callback) {
|
|
59
|
+
this.logStream.write(chunk);
|
|
60
|
+
this.push(chunk);
|
|
61
|
+
callback();
|
|
62
|
+
}
|
|
63
|
+
_flush(callback) {
|
|
64
|
+
this.logStream.end();
|
|
65
|
+
callback();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function inferPhase(msg) {
|
|
69
|
+
const method = msg.method;
|
|
70
|
+
const params = msg.params;
|
|
71
|
+
if (method === "item/commandExecution/started") {
|
|
72
|
+
const tool = params?.tool || params?.name || "";
|
|
73
|
+
const toolLower = tool.toLowerCase();
|
|
74
|
+
if (toolLower.includes("read") || toolLower.includes("glob") || toolLower.includes("grep") || toolLower.includes("search")) {
|
|
75
|
+
return "reading";
|
|
76
|
+
}
|
|
77
|
+
if (toolLower.includes("todowrite") || toolLower.includes("todo")) {
|
|
78
|
+
return "planning";
|
|
79
|
+
}
|
|
80
|
+
if (toolLower.includes("edit") || toolLower.includes("write") || toolLower.includes("bash")) {
|
|
81
|
+
return "implementing";
|
|
82
|
+
}
|
|
83
|
+
if (toolLower.includes("test")) {
|
|
84
|
+
return "testing";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (method === "item/commandExecution/started") {
|
|
88
|
+
const command = params?.command || "";
|
|
89
|
+
if (command.includes("git commit") || command.includes("git add")) {
|
|
90
|
+
return "committing";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
29
95
|
const DEFAULT_CONFIG = {
|
|
30
96
|
activeStates: ["Todo"],
|
|
31
97
|
terminalStates: ["Done", "Cancelled", "Canceled", "Closed"],
|
|
@@ -130,6 +196,7 @@ class Conductor {
|
|
|
130
196
|
console.log(
|
|
131
197
|
`Orchestrator started \u2014 polling every ${this.config.pollIntervalMs / 1e3}s, max ${this.config.maxConcurrent} concurrent`
|
|
132
198
|
);
|
|
199
|
+
this.writeStatusFile();
|
|
133
200
|
const shutdown = () => this.stop();
|
|
134
201
|
process.on("SIGINT", shutdown);
|
|
135
202
|
process.on("SIGTERM", shutdown);
|
|
@@ -175,6 +242,7 @@ class Conductor {
|
|
|
175
242
|
}
|
|
176
243
|
this.running.clear();
|
|
177
244
|
this.claimed.clear();
|
|
245
|
+
this.clearStatusFile();
|
|
178
246
|
console.log(
|
|
179
247
|
`Orchestrator stopped. Completed: ${this.completeCount}, Failed: ${this.failCount}`
|
|
180
248
|
);
|
|
@@ -198,6 +266,82 @@ class Conductor {
|
|
|
198
266
|
issues
|
|
199
267
|
};
|
|
200
268
|
}
|
|
269
|
+
// ── Status File ──
|
|
270
|
+
/**
|
|
271
|
+
* Write current conductor state to .stackmemory/conductor-status.json
|
|
272
|
+
* for consumption by `stackmemory dashboard` and other tools.
|
|
273
|
+
*/
|
|
274
|
+
writeStatusFile() {
|
|
275
|
+
const statusDir = join(this.config.repoRoot, ".stackmemory");
|
|
276
|
+
if (!existsSync(statusDir)) return;
|
|
277
|
+
const status = {
|
|
278
|
+
pid: process.pid,
|
|
279
|
+
startedAt: this.startedAt,
|
|
280
|
+
updatedAt: Date.now(),
|
|
281
|
+
running: Array.from(this.running.values()).map((r) => ({
|
|
282
|
+
identifier: r.issue.identifier,
|
|
283
|
+
title: r.issue.title,
|
|
284
|
+
status: r.status,
|
|
285
|
+
attempt: r.attempt,
|
|
286
|
+
startedAt: r.startedAt,
|
|
287
|
+
runtime: Date.now() - r.startedAt
|
|
288
|
+
})),
|
|
289
|
+
queued: Array.from(this.claimed).filter(
|
|
290
|
+
(id) => !this.running.has(id) && !this.completed.has(id)
|
|
291
|
+
).length,
|
|
292
|
+
completed: this.completeCount,
|
|
293
|
+
failed: this.failCount,
|
|
294
|
+
totalAttempts: this.totalAttempts,
|
|
295
|
+
maxConcurrent: this.config.maxConcurrent,
|
|
296
|
+
stopping: this.stopping
|
|
297
|
+
};
|
|
298
|
+
try {
|
|
299
|
+
writeFileSync(
|
|
300
|
+
join(statusDir, "conductor-status.json"),
|
|
301
|
+
JSON.stringify(status, null, 2)
|
|
302
|
+
);
|
|
303
|
+
} catch {
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
clearStatusFile() {
|
|
307
|
+
const statusPath = join(
|
|
308
|
+
this.config.repoRoot,
|
|
309
|
+
".stackmemory",
|
|
310
|
+
"conductor-status.json"
|
|
311
|
+
);
|
|
312
|
+
try {
|
|
313
|
+
if (existsSync(statusPath)) rmSync(statusPath);
|
|
314
|
+
} catch {
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// ── Agent Status Files ──
|
|
318
|
+
/**
|
|
319
|
+
* Write per-agent status to ~/.stackmemory/conductor/agents/<issue-id>/status.json
|
|
320
|
+
*/
|
|
321
|
+
writeAgentStatus(issueIdentifier, run) {
|
|
322
|
+
try {
|
|
323
|
+
const dir = ensureAgentStatusDir(issueIdentifier);
|
|
324
|
+
const status = {
|
|
325
|
+
issue: issueIdentifier,
|
|
326
|
+
pid: run.process?.pid || process.pid,
|
|
327
|
+
started: new Date(run.startedAt).toISOString(),
|
|
328
|
+
lastUpdate: (/* @__PURE__ */ new Date()).toISOString(),
|
|
329
|
+
phase: run.phase,
|
|
330
|
+
filesModified: run.filesModified,
|
|
331
|
+
toolCalls: run.toolCalls,
|
|
332
|
+
tokensUsed: run.tokensUsed
|
|
333
|
+
};
|
|
334
|
+
writeFileSync(join(dir, "status.json"), JSON.stringify(status, null, 2));
|
|
335
|
+
} catch {
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Open a log file write stream for an agent.
|
|
340
|
+
*/
|
|
341
|
+
openAgentLogStream(issueIdentifier) {
|
|
342
|
+
const dir = ensureAgentStatusDir(issueIdentifier);
|
|
343
|
+
return createWriteStream(join(dir, "output.log"), { flags: "a" });
|
|
344
|
+
}
|
|
201
345
|
// ── Polling ──
|
|
202
346
|
async schedulePoll() {
|
|
203
347
|
while (!this.stopping) {
|
|
@@ -210,6 +354,7 @@ class Conductor {
|
|
|
210
354
|
} catch (err) {
|
|
211
355
|
logger.error("Poll cycle failed", { error: err.message });
|
|
212
356
|
}
|
|
357
|
+
this.writeStatusFile();
|
|
213
358
|
}
|
|
214
359
|
}
|
|
215
360
|
async poll() {
|
|
@@ -337,8 +482,13 @@ class Conductor {
|
|
|
337
482
|
process: null,
|
|
338
483
|
attempt: 1,
|
|
339
484
|
startedAt: Date.now(),
|
|
340
|
-
status: "starting"
|
|
485
|
+
status: "starting",
|
|
486
|
+
phase: "reading",
|
|
487
|
+
toolCalls: 0,
|
|
488
|
+
filesModified: 0,
|
|
489
|
+
tokensUsed: 0
|
|
341
490
|
};
|
|
491
|
+
this.writeAgentStatus(issue.identifier, run);
|
|
342
492
|
this.running.set(issueId, run);
|
|
343
493
|
this.totalAttempts++;
|
|
344
494
|
console.log(`[${issue.identifier}] Dispatching: ${issue.title}`);
|
|
@@ -347,23 +497,26 @@ class Conductor {
|
|
|
347
497
|
run.workspacePath = workspacePath;
|
|
348
498
|
await this.transitionIssue(issue, this.config.inProgressState);
|
|
349
499
|
await this.runHook("after-create", workspacePath, issue);
|
|
350
|
-
|
|
351
|
-
await this.runAgent(issue, run);
|
|
352
|
-
run.status = "completed";
|
|
353
|
-
this.completeCount++;
|
|
354
|
-
await this.runHook("after-run", workspacePath, issue, run.attempt);
|
|
355
|
-
this.takeSnapshot(workspacePath, issue);
|
|
356
|
-
await this.transitionIssue(issue, this.config.inReviewState);
|
|
357
|
-
console.log(`[${issue.identifier}] Completed successfully`);
|
|
500
|
+
await this.attemptRun(issue, run);
|
|
358
501
|
} catch (err) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
502
|
+
this.failCount++;
|
|
503
|
+
console.log(`[${issue.identifier}] Failed: ${err.message}`);
|
|
504
|
+
} finally {
|
|
505
|
+
this.running.delete(issueId);
|
|
506
|
+
this.writeStatusFile();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Run the agent with retry logic. Throws on final failure.
|
|
511
|
+
*/
|
|
512
|
+
async attemptRun(issue, run) {
|
|
513
|
+
const maxAttempts = this.config.maxRetries + 1;
|
|
514
|
+
while (run.attempt <= maxAttempts) {
|
|
515
|
+
try {
|
|
516
|
+
run.status = "running";
|
|
517
|
+
await this.runAgent(issue, run);
|
|
518
|
+
run.status = "completed";
|
|
519
|
+
this.completeCount++;
|
|
367
520
|
await this.runHook(
|
|
368
521
|
"after-run",
|
|
369
522
|
run.workspacePath,
|
|
@@ -371,47 +524,44 @@ class Conductor {
|
|
|
371
524
|
run.attempt
|
|
372
525
|
).catch(() => {
|
|
373
526
|
});
|
|
374
|
-
|
|
375
|
-
|
|
527
|
+
this.takeSnapshot(run.workspacePath, issue);
|
|
528
|
+
await this.transitionIssue(issue, this.config.inReviewState);
|
|
376
529
|
console.log(
|
|
377
|
-
`[${issue.identifier}]
|
|
530
|
+
run.attempt === 1 ? `[${issue.identifier}] Completed successfully` : `[${issue.identifier}] Completed on retry ${run.attempt}`
|
|
378
531
|
);
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
)
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
532
|
+
return;
|
|
533
|
+
} catch (err) {
|
|
534
|
+
run.status = "failed";
|
|
535
|
+
run.error = err.message;
|
|
536
|
+
logger.error("Agent run failed", {
|
|
537
|
+
identifier: issue.identifier,
|
|
538
|
+
error: run.error,
|
|
539
|
+
attempt: run.attempt
|
|
540
|
+
});
|
|
541
|
+
if (run.workspacePath) {
|
|
542
|
+
await this.runHook(
|
|
543
|
+
"after-run",
|
|
544
|
+
run.workspacePath,
|
|
545
|
+
issue,
|
|
546
|
+
run.attempt
|
|
547
|
+
).catch(() => {
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
if (run.attempt < maxAttempts && !this.stopping) {
|
|
551
|
+
console.log(
|
|
552
|
+
`[${issue.identifier}] Failed (attempt ${run.attempt}), retrying...`
|
|
553
|
+
);
|
|
554
|
+
run.attempt++;
|
|
555
|
+
this.totalAttempts++;
|
|
556
|
+
const backoffMs = Math.min(
|
|
557
|
+
1e3 * Math.pow(2, run.attempt - 1),
|
|
558
|
+
3e5
|
|
559
|
+
);
|
|
560
|
+
await new Promise((r) => setTimeout(r, backoffMs));
|
|
561
|
+
} else {
|
|
562
|
+
throw err;
|
|
408
563
|
}
|
|
409
|
-
} else {
|
|
410
|
-
this.failCount++;
|
|
411
|
-
console.log(`[${issue.identifier}] Failed: ${run.error}`);
|
|
412
564
|
}
|
|
413
|
-
} finally {
|
|
414
|
-
this.running.delete(issueId);
|
|
415
565
|
}
|
|
416
566
|
}
|
|
417
567
|
// ── Workspace Management ──
|
|
@@ -507,6 +657,11 @@ class Conductor {
|
|
|
507
657
|
stdio: ["pipe", "pipe", "pipe"]
|
|
508
658
|
});
|
|
509
659
|
run.process = proc;
|
|
660
|
+
const logStream = this.openAgentLogStream(issue.identifier);
|
|
661
|
+
run.logStream = logStream;
|
|
662
|
+
const tee = new TeeTransform(logStream);
|
|
663
|
+
proc.stdout.pipe(tee);
|
|
664
|
+
this.writeAgentStatus(issue.identifier, run);
|
|
510
665
|
let stderr = "";
|
|
511
666
|
let turnCompleted = false;
|
|
512
667
|
const timer = setTimeout(() => {
|
|
@@ -525,7 +680,7 @@ class Conductor {
|
|
|
525
680
|
proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
526
681
|
};
|
|
527
682
|
let lineBuffer = "";
|
|
528
|
-
|
|
683
|
+
tee.on("data", (chunk) => {
|
|
529
684
|
lineBuffer += chunk.toString();
|
|
530
685
|
const lines = lineBuffer.split("\n");
|
|
531
686
|
lineBuffer = lines.pop() || "";
|
|
@@ -534,11 +689,33 @@ class Conductor {
|
|
|
534
689
|
try {
|
|
535
690
|
const msg = JSON.parse(line);
|
|
536
691
|
this.handleAgentMessage(msg, issue, run);
|
|
692
|
+
const phase = inferPhase(msg);
|
|
693
|
+
if (phase) {
|
|
694
|
+
run.phase = phase;
|
|
695
|
+
}
|
|
696
|
+
if (msg.method === "item/commandExecution/started" || msg.method === "item/toolUse/started") {
|
|
697
|
+
run.toolCalls++;
|
|
698
|
+
}
|
|
699
|
+
const params = msg.params;
|
|
700
|
+
if (msg.method === "item/commandExecution/started" && params) {
|
|
701
|
+
const tool = (params.tool || params.name || "").toLowerCase();
|
|
702
|
+
if (tool.includes("edit") || tool.includes("write")) {
|
|
703
|
+
run.filesModified++;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (msg.method === "item/text" && params?.text) {
|
|
707
|
+
run.tokensUsed += Math.ceil(params.text.length / 4);
|
|
708
|
+
}
|
|
709
|
+
if (run.toolCalls % 5 === 0 || phase) {
|
|
710
|
+
this.writeAgentStatus(issue.identifier, run);
|
|
711
|
+
}
|
|
537
712
|
if (msg.method === "turn/completed") {
|
|
538
713
|
turnCompleted = true;
|
|
714
|
+
this.writeAgentStatus(issue.identifier, run);
|
|
539
715
|
}
|
|
540
716
|
if (msg.method === "turn/failed") {
|
|
541
717
|
turnCompleted = true;
|
|
718
|
+
this.writeAgentStatus(issue.identifier, run);
|
|
542
719
|
const errMsg = msg.params?.error?.message || "Agent turn failed";
|
|
543
720
|
clearTimeout(timer);
|
|
544
721
|
reject(new Error(errMsg));
|
|
@@ -562,6 +739,10 @@ class Conductor {
|
|
|
562
739
|
proc.on("close", (code) => {
|
|
563
740
|
clearTimeout(timer);
|
|
564
741
|
run.process = null;
|
|
742
|
+
if (run.logStream && !run.logStream.destroyed) {
|
|
743
|
+
run.logStream.end();
|
|
744
|
+
}
|
|
745
|
+
this.writeAgentStatus(issue.identifier, run);
|
|
565
746
|
if (turnCompleted) {
|
|
566
747
|
resolve();
|
|
567
748
|
} else if (code === 0) {
|
|
@@ -800,5 +981,6 @@ class Conductor {
|
|
|
800
981
|
}
|
|
801
982
|
}
|
|
802
983
|
export {
|
|
803
|
-
Conductor
|
|
984
|
+
Conductor,
|
|
985
|
+
getAgentStatusDir
|
|
804
986
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stackmemoryai/stackmemory",
|
|
3
|
-
"version": "1.5.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.5.3",
|
|
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",
|
|
7
7
|
"npm": ">=10.0.0"
|