volute 0.1.0 → 0.2.0
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 +1 -2
- package/dist/agent-manager-SSJUZWOV.js +13 -0
- package/dist/{channel-Q642YUZE.js → channel-2WJRM7PE.js} +2 -2
- package/dist/{chunk-H5XQARAP.js → chunk-4YXYAMFT.js} +3 -3
- package/dist/{chunk-5YW4B7CG.js → chunk-6UCG6MIX.js} +72 -23
- package/dist/{chunk-A5ZJEMHT.js → chunk-KFNNHQK7.js} +4 -4
- package/dist/chunk-L3BQEZ4Z.js +271 -0
- package/dist/{chunk-N4QN44LC.js → chunk-MY74SUOL.js} +29 -22
- package/dist/{chunk-KSMIWOCN.js → chunk-N4YNKR3Q.js} +6 -0
- package/dist/cli.js +23 -19
- package/dist/{connect-LW6G23AV.js → connect-X5V5IMRW.js} +3 -3
- package/dist/connectors/discord.js +9 -2
- package/dist/{create-3K6O2SDC.js → create-23AM7H5B.js} +1 -1
- package/dist/{daemon-client-ZTHW7ROS.js → daemon-client-VN24HM5T.js} +2 -2
- package/dist/daemon.js +394 -436
- package/dist/{delete-JNGY7ZFH.js → delete-GDMSOW3U.js} +2 -2
- package/dist/{disconnect-ACVTKTRE.js → disconnect-5JWFZ6RV.js} +2 -2
- package/dist/{down-FYCUYC5H.js → down-WTF73FE7.js} +5 -4
- package/dist/{env-7SLRN3MG.js → env-YKUJOFHE.js} +12 -5
- package/dist/{fork-BB3DZ426.js → fork-GRSVMBKI.js} +39 -32
- package/dist/history-7WVVKMUY.js +46 -0
- package/dist/{import-W2AMTEV5.js → import-42DOLBDT.js} +1 -1
- package/dist/{logs-BUHRIQ2L.js → logs-SYRQOL6B.js} +1 -1
- package/dist/{merge-446QTE7Q.js → merge-CSAVLSLY.js} +33 -36
- package/dist/{schedule-KKSOVUDF.js → schedule-J37XQM6E.js} +2 -2
- package/dist/{send-WQSVSRDD.js → send-PLOYEYER.js} +7 -5
- package/dist/{start-LKMWS6ZE.js → start-AG7QLULK.js} +2 -2
- package/dist/{status-CIEKUI3V.js → status-GCNU4M3K.js} +9 -2
- package/dist/{stop-YTOAGYE4.js → stop-IL5Q6NER.js} +2 -2
- package/dist/{up-AJJ4GCXY.js → up-AMAP7JG7.js} +11 -8
- package/dist/{upgrade-JACA6YMO.js → upgrade-DD5TNJWU.js} +3 -5
- package/dist/{variants-HPY4DEWU.js → variants-QQIEKT6M.js} +2 -2
- package/package.json +1 -1
- package/templates/_base/.init/.config/hooks/startup-context.sh +28 -0
- package/templates/_base/_skills/memory/SKILL.md +56 -13
- package/templates/_base/_skills/volute-agent/SKILL.md +27 -3
- package/templates/_base/home/VOLUTE.md +25 -0
- package/templates/_base/src/lib/format-prefix.ts +24 -0
- package/templates/_base/src/lib/sessions.ts +71 -0
- package/templates/_base/src/lib/startup.ts +132 -0
- package/templates/_base/src/lib/types.ts +3 -0
- package/templates/_base/src/lib/volute-server.ts +18 -2
- package/templates/agent-sdk/.init/.claude/settings.json +14 -0
- package/templates/agent-sdk/.init/.config/sessions.json +4 -0
- package/templates/agent-sdk/.init/CLAUDE.md +3 -2
- package/templates/agent-sdk/src/agent.ts +101 -0
- package/templates/agent-sdk/src/lib/agent-sessions.ts +180 -0
- package/templates/agent-sdk/src/server.ts +33 -129
- package/templates/agent-sdk/volute-template.json +1 -1
- package/templates/pi/.init/.config/sessions.json +1 -0
- package/templates/pi/.init/AGENTS.md +2 -1
- package/templates/pi/src/agent.ts +61 -0
- package/templates/pi/src/lib/agent-sessions.ts +188 -0
- package/templates/pi/src/server.ts +28 -102
- package/templates/pi/volute-template.json +1 -1
- package/templates/agent-sdk/src/lib/agent.ts +0 -199
- package/templates/pi/src/lib/agent.ts +0 -205
- /package/templates/_base/.init/memory/{.gitkeep → journal/.gitkeep} +0 -0
- /package/templates/_base/{volute.json.tmpl → home/.config/volute.json.tmpl} +0 -0
- /package/templates/pi/{volute.json.tmpl → home/.config/volute.json.tmpl} +0 -0
package/README.md
CHANGED
|
@@ -71,7 +71,6 @@ Responses stream back to your terminal in real time. The agent knows which chann
|
|
|
71
71
|
│ ├── VOLUTE.md # channel routing docs
|
|
72
72
|
│ └── memory/ # daily logs (YYYY-MM-DD.md)
|
|
73
73
|
├── src/ # agent server code
|
|
74
|
-
├── volute.json # agent config (model, etc.)
|
|
75
74
|
└── .volute/ # runtime state, session, logs
|
|
76
75
|
```
|
|
77
76
|
|
|
@@ -210,7 +209,7 @@ volute create atlas --template pi
|
|
|
210
209
|
|
|
211
210
|
## Model configuration
|
|
212
211
|
|
|
213
|
-
Set the model via `volute.json` in the agent
|
|
212
|
+
Set the model via `home/.config/volute.json` in the agent directory, or the `VOLUTE_MODEL` env var.
|
|
214
213
|
|
|
215
214
|
## Development
|
|
216
215
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
loadMergedEnv
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-KFNNHQK7.js";
|
|
5
5
|
import {
|
|
6
6
|
parseArgs
|
|
7
7
|
} from "./chunk-D424ZQGI.js";
|
|
8
8
|
import {
|
|
9
9
|
resolveAgent
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-6UCG6MIX.js";
|
|
11
11
|
|
|
12
12
|
// src/lib/channels/discord.ts
|
|
13
13
|
var API_BASE = "https://discord.com/api/v10";
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
} from "./chunk-
|
|
3
|
+
voluteHome
|
|
4
|
+
} from "./chunk-6UCG6MIX.js";
|
|
5
5
|
|
|
6
6
|
// src/lib/daemon-client.ts
|
|
7
7
|
import { existsSync, readFileSync } from "fs";
|
|
8
8
|
import { resolve } from "path";
|
|
9
9
|
function readDaemonConfig() {
|
|
10
|
-
const configPath = resolve(
|
|
10
|
+
const configPath = resolve(voluteHome(), "daemon.json");
|
|
11
11
|
if (!existsSync(configPath)) {
|
|
12
12
|
console.error("Volute is not running. Start with: volute up");
|
|
13
13
|
process.exit(1);
|
|
@@ -9,19 +9,24 @@ var __export = (target, all) => {
|
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
10
10
|
import { homedir } from "os";
|
|
11
11
|
import { resolve } from "path";
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
function voluteHome() {
|
|
13
|
+
return process.env.VOLUTE_HOME || resolve(homedir(), ".volute");
|
|
14
|
+
}
|
|
15
|
+
function variantsPath() {
|
|
16
|
+
return resolve(voluteHome(), "variants.json");
|
|
17
|
+
}
|
|
14
18
|
function readAllVariants() {
|
|
15
|
-
|
|
19
|
+
const path = variantsPath();
|
|
20
|
+
if (!existsSync(path)) return {};
|
|
16
21
|
try {
|
|
17
|
-
return JSON.parse(readFileSync(
|
|
22
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
18
23
|
} catch {
|
|
19
24
|
return {};
|
|
20
25
|
}
|
|
21
26
|
}
|
|
22
27
|
function writeAllVariants(all) {
|
|
23
|
-
mkdirSync(
|
|
24
|
-
writeFileSync(
|
|
28
|
+
mkdirSync(voluteHome(), { recursive: true });
|
|
29
|
+
writeFileSync(variantsPath(), `${JSON.stringify(all, null, 2)}
|
|
25
30
|
`);
|
|
26
31
|
}
|
|
27
32
|
function readVariants(agentName) {
|
|
@@ -52,6 +57,28 @@ function removeVariant(agentName, name) {
|
|
|
52
57
|
function findVariant(agentName, name) {
|
|
53
58
|
return readVariants(agentName).find((v) => v.name === name);
|
|
54
59
|
}
|
|
60
|
+
function setVariantRunning(agentName, variantName, running) {
|
|
61
|
+
const all = readAllVariants();
|
|
62
|
+
const variants = all[agentName] ?? [];
|
|
63
|
+
const variant = variants.find((v) => v.name === variantName);
|
|
64
|
+
if (variant) {
|
|
65
|
+
variant.running = running;
|
|
66
|
+
all[agentName] = variants;
|
|
67
|
+
writeAllVariants(all);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function getAllRunningVariants() {
|
|
71
|
+
const all = readAllVariants();
|
|
72
|
+
const result = [];
|
|
73
|
+
for (const [agentName, variants] of Object.entries(all)) {
|
|
74
|
+
for (const variant of variants) {
|
|
75
|
+
if (variant.running) {
|
|
76
|
+
result.push({ agentName, variant });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
55
82
|
function removeAllVariants(agentName) {
|
|
56
83
|
const all = readAllVariants();
|
|
57
84
|
delete all[agentName];
|
|
@@ -81,19 +108,20 @@ function validateBranchName(branch) {
|
|
|
81
108
|
}
|
|
82
109
|
|
|
83
110
|
// src/lib/registry.ts
|
|
84
|
-
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
111
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync2 } from "fs";
|
|
85
112
|
import { homedir as homedir2 } from "os";
|
|
86
113
|
import { resolve as resolve2 } from "path";
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
114
|
+
function voluteHome2() {
|
|
115
|
+
return process.env.VOLUTE_HOME || resolve2(homedir2(), ".volute");
|
|
116
|
+
}
|
|
90
117
|
function ensureVoluteHome() {
|
|
91
|
-
mkdirSync2(
|
|
118
|
+
mkdirSync2(resolve2(voluteHome2(), "agents"), { recursive: true });
|
|
92
119
|
}
|
|
93
120
|
function readRegistry() {
|
|
94
|
-
|
|
121
|
+
const registryPath = resolve2(voluteHome2(), "agents.json");
|
|
122
|
+
if (!existsSync2(registryPath)) return [];
|
|
95
123
|
try {
|
|
96
|
-
const entries = JSON.parse(readFileSync2(
|
|
124
|
+
const entries = JSON.parse(readFileSync2(registryPath, "utf-8"));
|
|
97
125
|
return entries.map((e) => ({ ...e, running: e.running ?? false }));
|
|
98
126
|
} catch {
|
|
99
127
|
return [];
|
|
@@ -101,10 +129,26 @@ function readRegistry() {
|
|
|
101
129
|
}
|
|
102
130
|
function writeRegistry(entries) {
|
|
103
131
|
ensureVoluteHome();
|
|
104
|
-
|
|
132
|
+
const registryPath = resolve2(voluteHome2(), "agents.json");
|
|
133
|
+
const tmpPath = `${registryPath}.tmp`;
|
|
134
|
+
writeFileSync2(tmpPath, `${JSON.stringify(entries, null, 2)}
|
|
105
135
|
`);
|
|
136
|
+
renameSync(tmpPath, registryPath);
|
|
137
|
+
}
|
|
138
|
+
var AGENT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
139
|
+
var AGENT_NAME_MAX = 64;
|
|
140
|
+
function validateAgentName(name) {
|
|
141
|
+
if (!name) return "Agent name is required";
|
|
142
|
+
if (name.length > AGENT_NAME_MAX)
|
|
143
|
+
return `Agent name must be at most ${AGENT_NAME_MAX} characters`;
|
|
144
|
+
if (!AGENT_NAME_RE.test(name)) {
|
|
145
|
+
return "Agent name must start with alphanumeric and contain only alphanumeric, dots, dashes, or underscores";
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
106
148
|
}
|
|
107
149
|
function addAgent(name, port) {
|
|
150
|
+
const err = validateAgentName(name);
|
|
151
|
+
if (err) throw new Error(err);
|
|
108
152
|
const entries = readRegistry();
|
|
109
153
|
const filtered = entries.filter((e) => e.name !== name);
|
|
110
154
|
filtered.push({ name, port, created: (/* @__PURE__ */ new Date()).toISOString(), running: false });
|
|
@@ -126,34 +170,37 @@ function findAgent(name) {
|
|
|
126
170
|
return readRegistry().find((e) => e.name === name);
|
|
127
171
|
}
|
|
128
172
|
function agentDir(name) {
|
|
129
|
-
return resolve2(
|
|
173
|
+
return resolve2(voluteHome2(), "agents", name);
|
|
130
174
|
}
|
|
131
175
|
function nextPort() {
|
|
132
176
|
const entries = readRegistry();
|
|
133
177
|
const usedPorts = new Set(entries.map((e) => e.port));
|
|
178
|
+
for (const entry of entries) {
|
|
179
|
+
for (const v of readVariants(entry.name)) {
|
|
180
|
+
if (v.port) usedPorts.add(v.port);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
134
183
|
let port = 4100;
|
|
135
184
|
while (usedPorts.has(port)) port++;
|
|
185
|
+
if (port > 65535) throw new Error("No available ports \u2014 all ports 4100-65535 are allocated");
|
|
136
186
|
return port;
|
|
137
187
|
}
|
|
138
188
|
function resolveAgent(name) {
|
|
139
189
|
const [baseName, variantName] = name.split("@", 2);
|
|
140
190
|
const entry = findAgent(baseName);
|
|
141
191
|
if (!entry) {
|
|
142
|
-
|
|
143
|
-
process.exit(1);
|
|
192
|
+
throw new Error(`Unknown agent: ${baseName}`);
|
|
144
193
|
}
|
|
145
194
|
const dir = agentDir(baseName);
|
|
146
195
|
if (!existsSync2(dir)) {
|
|
147
|
-
|
|
148
|
-
process.exit(1);
|
|
196
|
+
throw new Error(`Agent directory missing: ${dir}`);
|
|
149
197
|
}
|
|
150
198
|
if (variantName) {
|
|
151
199
|
const variant = findVariant(baseName, variantName);
|
|
152
200
|
if (!variant) {
|
|
153
|
-
|
|
154
|
-
process.exit(1);
|
|
201
|
+
throw new Error(`Unknown variant: ${variantName} (agent: ${baseName})`);
|
|
155
202
|
}
|
|
156
|
-
return { entry: { ...entry, port: variant.port }, dir };
|
|
203
|
+
return { entry: { ...entry, port: variant.port }, dir: variant.path };
|
|
157
204
|
}
|
|
158
205
|
return { entry, dir };
|
|
159
206
|
}
|
|
@@ -165,10 +212,12 @@ export {
|
|
|
165
212
|
addVariant,
|
|
166
213
|
removeVariant,
|
|
167
214
|
findVariant,
|
|
215
|
+
setVariantRunning,
|
|
216
|
+
getAllRunningVariants,
|
|
168
217
|
removeAllVariants,
|
|
169
218
|
checkHealth,
|
|
170
219
|
validateBranchName,
|
|
171
|
-
|
|
220
|
+
voluteHome2 as voluteHome,
|
|
172
221
|
ensureVoluteHome,
|
|
173
222
|
readRegistry,
|
|
174
223
|
addAgent,
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
} from "./chunk-
|
|
3
|
+
voluteHome
|
|
4
|
+
} from "./chunk-6UCG6MIX.js";
|
|
5
5
|
|
|
6
6
|
// src/lib/env.ts
|
|
7
7
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
8
8
|
import { dirname, resolve } from "path";
|
|
9
9
|
function sharedEnvPath() {
|
|
10
|
-
return resolve(
|
|
10
|
+
return resolve(voluteHome(), "env.json");
|
|
11
11
|
}
|
|
12
12
|
function agentEnvPath(agentDir) {
|
|
13
13
|
return resolve(agentDir, ".volute", "env.json");
|
|
@@ -23,7 +23,7 @@ function readEnv(path) {
|
|
|
23
23
|
function writeEnv(path, env) {
|
|
24
24
|
mkdirSync(dirname(path), { recursive: true });
|
|
25
25
|
writeFileSync(path, `${JSON.stringify(env, null, 2)}
|
|
26
|
-
|
|
26
|
+
`, { mode: 384 });
|
|
27
27
|
}
|
|
28
28
|
function loadMergedEnv(agentDir) {
|
|
29
29
|
const shared = readEnv(sharedEnvPath());
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
loadMergedEnv
|
|
4
|
+
} from "./chunk-KFNNHQK7.js";
|
|
5
|
+
import {
|
|
6
|
+
agentDir,
|
|
7
|
+
findAgent,
|
|
8
|
+
findVariant,
|
|
9
|
+
setAgentRunning,
|
|
10
|
+
setVariantRunning,
|
|
11
|
+
validateBranchName
|
|
12
|
+
} from "./chunk-6UCG6MIX.js";
|
|
13
|
+
|
|
14
|
+
// src/lib/agent-manager.ts
|
|
15
|
+
import { execFile, spawn } from "child_process";
|
|
16
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync } from "fs";
|
|
17
|
+
import { resolve } from "path";
|
|
18
|
+
import { promisify } from "util";
|
|
19
|
+
var execFileAsync = promisify(execFile);
|
|
20
|
+
var MAX_RESTART_ATTEMPTS = 5;
|
|
21
|
+
var BASE_RESTART_DELAY = 3e3;
|
|
22
|
+
var MAX_RESTART_DELAY = 6e4;
|
|
23
|
+
var AgentManager = class {
|
|
24
|
+
agents = /* @__PURE__ */ new Map();
|
|
25
|
+
stopping = /* @__PURE__ */ new Set();
|
|
26
|
+
shuttingDown = false;
|
|
27
|
+
restartAttempts = /* @__PURE__ */ new Map();
|
|
28
|
+
resolveTarget(name) {
|
|
29
|
+
const [baseName, variantName] = name.split("@", 2);
|
|
30
|
+
const entry = findAgent(baseName);
|
|
31
|
+
if (!entry) throw new Error(`Unknown agent: ${baseName}`);
|
|
32
|
+
if (variantName) {
|
|
33
|
+
const variant = findVariant(baseName, variantName);
|
|
34
|
+
if (!variant) throw new Error(`Unknown variant: ${variantName} (agent: ${baseName})`);
|
|
35
|
+
return { dir: variant.path, port: variant.port, isVariant: true, baseName, variantName };
|
|
36
|
+
}
|
|
37
|
+
const dir = agentDir(baseName);
|
|
38
|
+
if (!existsSync(dir)) throw new Error(`Agent directory missing: ${dir}`);
|
|
39
|
+
return { dir, port: entry.port, isVariant: false, baseName };
|
|
40
|
+
}
|
|
41
|
+
async startAgent(name) {
|
|
42
|
+
if (this.agents.has(name)) {
|
|
43
|
+
throw new Error(`Agent ${name} is already running`);
|
|
44
|
+
}
|
|
45
|
+
const target = this.resolveTarget(name);
|
|
46
|
+
const { dir, isVariant, baseName, variantName } = target;
|
|
47
|
+
const port = target.port;
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`http://localhost:${port}/health`);
|
|
50
|
+
if (res.ok) {
|
|
51
|
+
console.error(`[daemon] killing orphan process on port ${port}`);
|
|
52
|
+
await killProcessOnPort(port);
|
|
53
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
const voluteDir = resolve(dir, ".volute");
|
|
58
|
+
const logsDir = resolve(voluteDir, "logs");
|
|
59
|
+
mkdirSync(logsDir, { recursive: true });
|
|
60
|
+
const logStream = createWriteStream(resolve(logsDir, "agent.log"), {
|
|
61
|
+
flags: "a"
|
|
62
|
+
});
|
|
63
|
+
const agentEnv = loadMergedEnv(dir);
|
|
64
|
+
const { VOLUTE_DAEMON_TOKEN: _, ...parentEnv } = process.env;
|
|
65
|
+
const env = { ...parentEnv, ...agentEnv, VOLUTE_AGENT: name };
|
|
66
|
+
const tsxBin = resolve(dir, "node_modules", ".bin", "tsx");
|
|
67
|
+
const child = spawn(tsxBin, ["src/server.ts", "--port", String(port)], {
|
|
68
|
+
cwd: dir,
|
|
69
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
70
|
+
detached: true,
|
|
71
|
+
env
|
|
72
|
+
});
|
|
73
|
+
this.agents.set(name, { child, port });
|
|
74
|
+
child.stdout?.pipe(logStream);
|
|
75
|
+
child.stderr?.pipe(logStream);
|
|
76
|
+
try {
|
|
77
|
+
await new Promise((resolve2, reject) => {
|
|
78
|
+
const timeout = setTimeout(() => {
|
|
79
|
+
reject(new Error(`Agent ${name} did not start within 30s`));
|
|
80
|
+
}, 3e4);
|
|
81
|
+
function checkOutput(data) {
|
|
82
|
+
if (data.toString().match(/listening on :\d+/)) {
|
|
83
|
+
clearTimeout(timeout);
|
|
84
|
+
resolve2();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
child.stdout?.on("data", checkOutput);
|
|
88
|
+
child.stderr?.on("data", checkOutput);
|
|
89
|
+
child.on("error", (err) => {
|
|
90
|
+
clearTimeout(timeout);
|
|
91
|
+
reject(err);
|
|
92
|
+
});
|
|
93
|
+
child.on("exit", (code) => {
|
|
94
|
+
clearTimeout(timeout);
|
|
95
|
+
reject(new Error(`Agent ${name} exited with code ${code} during startup`));
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
} catch (err) {
|
|
99
|
+
this.agents.delete(name);
|
|
100
|
+
try {
|
|
101
|
+
child.kill();
|
|
102
|
+
} catch {
|
|
103
|
+
}
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
this.restartAttempts.delete(name);
|
|
107
|
+
this.setupCrashRecovery(name, child, dir, isVariant);
|
|
108
|
+
if (isVariant) {
|
|
109
|
+
setVariantRunning(baseName, variantName, true);
|
|
110
|
+
} else {
|
|
111
|
+
setAgentRunning(name, true);
|
|
112
|
+
}
|
|
113
|
+
console.error(`[daemon] started agent ${name} on port ${port}`);
|
|
114
|
+
}
|
|
115
|
+
setupCrashRecovery(name, child, dir, isVariant) {
|
|
116
|
+
child.on("exit", async (code) => {
|
|
117
|
+
this.agents.delete(name);
|
|
118
|
+
if (this.shuttingDown || this.stopping.has(name)) return;
|
|
119
|
+
console.error(`[daemon] agent ${name} exited with code ${code}`);
|
|
120
|
+
const wasRestart = isVariant ? false : await this.handleRestart(name, dir);
|
|
121
|
+
if (wasRestart) {
|
|
122
|
+
console.error(`[daemon] restarting ${name} immediately after merge`);
|
|
123
|
+
this.restartAttempts.delete(name);
|
|
124
|
+
this.startAgent(name).catch((err) => {
|
|
125
|
+
console.error(`[daemon] failed to restart ${name} after merge:`, err);
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
const attempts = this.restartAttempts.get(name) ?? 0;
|
|
129
|
+
if (attempts >= MAX_RESTART_ATTEMPTS) {
|
|
130
|
+
console.error(`[daemon] ${name} crashed ${attempts} times \u2014 giving up on restart`);
|
|
131
|
+
const [base, variant] = name.split("@", 2);
|
|
132
|
+
if (variant) {
|
|
133
|
+
setVariantRunning(base, variant, false);
|
|
134
|
+
} else {
|
|
135
|
+
setAgentRunning(name, false);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const delay = Math.min(BASE_RESTART_DELAY * 2 ** attempts, MAX_RESTART_DELAY);
|
|
140
|
+
this.restartAttempts.set(name, attempts + 1);
|
|
141
|
+
console.error(
|
|
142
|
+
`[daemon] crash recovery for ${name} \u2014 attempt ${attempts + 1}/${MAX_RESTART_ATTEMPTS}, restarting in ${delay}ms`
|
|
143
|
+
);
|
|
144
|
+
setTimeout(() => {
|
|
145
|
+
if (this.shuttingDown) return;
|
|
146
|
+
this.startAgent(name).catch((err) => {
|
|
147
|
+
console.error(`[daemon] failed to restart ${name}:`, err);
|
|
148
|
+
});
|
|
149
|
+
}, delay);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
async handleRestart(name, dir) {
|
|
154
|
+
const restartPath = resolve(dir, ".volute", "restart.json");
|
|
155
|
+
if (!existsSync(restartPath)) return false;
|
|
156
|
+
try {
|
|
157
|
+
const signal = JSON.parse(readFileSync(restartPath, "utf-8"));
|
|
158
|
+
unlinkSync(restartPath);
|
|
159
|
+
if (signal.action === "merge" && signal.name) {
|
|
160
|
+
const err = validateBranchName(signal.name);
|
|
161
|
+
if (err) {
|
|
162
|
+
console.error(`[daemon] invalid variant name in restart.json for ${name}: ${err}`);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
console.error(`[daemon] merging variant for ${name}: ${signal.name}`);
|
|
166
|
+
const mergeArgs = ["merge", name, signal.name];
|
|
167
|
+
if (signal.summary) mergeArgs.push("--summary", signal.summary);
|
|
168
|
+
if (signal.justification) mergeArgs.push("--justification", signal.justification);
|
|
169
|
+
if (signal.memory) mergeArgs.push("--memory", signal.memory);
|
|
170
|
+
const { VOLUTE_DAEMON_TOKEN: _t, ...mergeEnv } = process.env;
|
|
171
|
+
await execFileAsync("volute", mergeArgs, {
|
|
172
|
+
cwd: dir,
|
|
173
|
+
env: { ...mergeEnv, VOLUTE_SUPERVISOR: "1" }
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
} catch (e) {
|
|
178
|
+
console.error(`[daemon] failed to handle restart for ${name}:`, e);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async stopAgent(name) {
|
|
183
|
+
const tracked = this.agents.get(name);
|
|
184
|
+
if (!tracked) return;
|
|
185
|
+
this.stopping.add(name);
|
|
186
|
+
const { child } = tracked;
|
|
187
|
+
this.agents.delete(name);
|
|
188
|
+
await new Promise((resolve2) => {
|
|
189
|
+
child.on("exit", () => resolve2());
|
|
190
|
+
try {
|
|
191
|
+
process.kill(-child.pid, "SIGTERM");
|
|
192
|
+
} catch {
|
|
193
|
+
resolve2();
|
|
194
|
+
}
|
|
195
|
+
setTimeout(() => {
|
|
196
|
+
try {
|
|
197
|
+
process.kill(-child.pid, "SIGKILL");
|
|
198
|
+
} catch {
|
|
199
|
+
}
|
|
200
|
+
resolve2();
|
|
201
|
+
}, 5e3);
|
|
202
|
+
});
|
|
203
|
+
this.stopping.delete(name);
|
|
204
|
+
this.restartAttempts.delete(name);
|
|
205
|
+
const [baseName, variantName] = name.split("@", 2);
|
|
206
|
+
if (variantName) {
|
|
207
|
+
setVariantRunning(baseName, variantName, false);
|
|
208
|
+
} else {
|
|
209
|
+
setAgentRunning(name, false);
|
|
210
|
+
}
|
|
211
|
+
console.error(`[daemon] stopped agent ${name}`);
|
|
212
|
+
}
|
|
213
|
+
async restartAgent(name) {
|
|
214
|
+
await this.stopAgent(name);
|
|
215
|
+
await this.startAgent(name);
|
|
216
|
+
}
|
|
217
|
+
async stopAll() {
|
|
218
|
+
this.shuttingDown = true;
|
|
219
|
+
const names = [...this.agents.keys()];
|
|
220
|
+
await Promise.all(names.map((name) => this.stopAgent(name)));
|
|
221
|
+
}
|
|
222
|
+
isRunning(name) {
|
|
223
|
+
return this.agents.has(name);
|
|
224
|
+
}
|
|
225
|
+
getRunningAgents() {
|
|
226
|
+
return [...this.agents.keys()];
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
async function killProcessOnPort(port) {
|
|
230
|
+
try {
|
|
231
|
+
const { stdout } = await execFileAsync("lsof", ["-ti", `:${port}`, "-sTCP:LISTEN"]);
|
|
232
|
+
const pids = /* @__PURE__ */ new Set();
|
|
233
|
+
for (const line of stdout.trim().split("\n").filter(Boolean)) {
|
|
234
|
+
const pid = parseInt(line, 10);
|
|
235
|
+
pids.add(pid);
|
|
236
|
+
try {
|
|
237
|
+
const { stdout: psOut } = await execFileAsync("ps", ["-p", String(pid), "-o", "pgid="]);
|
|
238
|
+
const pgid = parseInt(psOut.trim(), 10);
|
|
239
|
+
if (pgid > 1) pids.add(pgid);
|
|
240
|
+
} catch {
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (const pid of pids) {
|
|
244
|
+
try {
|
|
245
|
+
process.kill(-pid, "SIGTERM");
|
|
246
|
+
} catch {
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
process.kill(pid, "SIGTERM");
|
|
250
|
+
} catch {
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
var instance = null;
|
|
257
|
+
function initAgentManager() {
|
|
258
|
+
if (instance) throw new Error("AgentManager already initialized");
|
|
259
|
+
instance = new AgentManager();
|
|
260
|
+
return instance;
|
|
261
|
+
}
|
|
262
|
+
function getAgentManager() {
|
|
263
|
+
if (!instance) instance = new AgentManager();
|
|
264
|
+
return instance;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export {
|
|
268
|
+
AgentManager,
|
|
269
|
+
initAgentManager,
|
|
270
|
+
getAgentManager
|
|
271
|
+
};
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/lib/spawn-server.ts
|
|
4
4
|
import { spawn } from "child_process";
|
|
5
|
+
import { closeSync, mkdirSync, openSync, readFileSync } from "fs";
|
|
5
6
|
import { resolve } from "path";
|
|
6
7
|
function tsxBin(cwd) {
|
|
7
8
|
return resolve(cwd, "node_modules", ".bin", "tsx");
|
|
@@ -39,33 +40,39 @@ function spawnAttached(cwd, port) {
|
|
|
39
40
|
});
|
|
40
41
|
}
|
|
41
42
|
function spawnDetached(cwd, port) {
|
|
43
|
+
const logsDir = resolve(cwd, ".volute", "logs");
|
|
44
|
+
mkdirSync(logsDir, { recursive: true });
|
|
45
|
+
const logPath = resolve(logsDir, "agent.log");
|
|
46
|
+
const logFd = openSync(logPath, "a");
|
|
42
47
|
const child = spawn(tsxBin(cwd), ["src/server.ts", "--port", String(port)], {
|
|
43
48
|
cwd,
|
|
44
|
-
stdio: ["ignore",
|
|
49
|
+
stdio: ["ignore", logFd, logFd],
|
|
45
50
|
detached: true
|
|
46
51
|
});
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
child.unref();
|
|
56
|
-
resolve2({ child, actualPort: parseInt(match[1], 10) });
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
child.stdout?.on("data", checkOutput);
|
|
60
|
-
child.stderr?.on("data", checkOutput);
|
|
61
|
-
child.on("error", () => {
|
|
62
|
-
clearTimeout(timeout);
|
|
63
|
-
resolve2(null);
|
|
64
|
-
});
|
|
65
|
-
child.on("exit", () => {
|
|
52
|
+
child.unref();
|
|
53
|
+
closeSync(logFd);
|
|
54
|
+
return new Promise((res) => {
|
|
55
|
+
let done = false;
|
|
56
|
+
function finish(result) {
|
|
57
|
+
if (done) return;
|
|
58
|
+
done = true;
|
|
59
|
+
clearInterval(interval);
|
|
66
60
|
clearTimeout(timeout);
|
|
67
|
-
|
|
68
|
-
}
|
|
61
|
+
res(result);
|
|
62
|
+
}
|
|
63
|
+
const interval = setInterval(() => {
|
|
64
|
+
try {
|
|
65
|
+
const content = readFileSync(logPath, "utf-8");
|
|
66
|
+
const match = content.match(/listening on :(\d+)/);
|
|
67
|
+
if (match) {
|
|
68
|
+
finish({ child, actualPort: parseInt(match[1], 10) });
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
}
|
|
72
|
+
}, 100);
|
|
73
|
+
const timeout = setTimeout(() => finish(null), 3e4);
|
|
74
|
+
child.on("error", () => finish(null));
|
|
75
|
+
child.on("exit", () => finish(null));
|
|
69
76
|
});
|
|
70
77
|
}
|
|
71
78
|
|
|
@@ -45,6 +45,7 @@ var log = {
|
|
|
45
45
|
var logger_default = log;
|
|
46
46
|
|
|
47
47
|
// src/lib/ndjson.ts
|
|
48
|
+
var MAX_BUFFER_SIZE = 1e6;
|
|
48
49
|
async function* readNdjson(body) {
|
|
49
50
|
const reader = body.getReader();
|
|
50
51
|
const decoder = new TextDecoder();
|
|
@@ -54,6 +55,11 @@ async function* readNdjson(body) {
|
|
|
54
55
|
const { done, value } = await reader.read();
|
|
55
56
|
if (done) break;
|
|
56
57
|
buffer += decoder.decode(value, { stream: true });
|
|
58
|
+
if (buffer.length > MAX_BUFFER_SIZE) {
|
|
59
|
+
logger_default.warn("ndjson: buffer exceeded 1MB, resetting");
|
|
60
|
+
buffer = "";
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
57
63
|
const lines = buffer.split("\n");
|
|
58
64
|
buffer = lines.pop() || "";
|
|
59
65
|
for (const line of lines) {
|