@holon-run/agentinbox 1.5.0 → 1.5.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.
- package/dist/src/cli.js +24 -5
- package/dist/src/daemon.js +219 -39
- package/dist/src/paths.js +8 -0
- package/dist/src/service.js +15 -2
- package/dist/src/store.js +41 -2
- package/dist/src/util.js +29 -0
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -1019,6 +1019,25 @@ async function runServe(args) {
|
|
|
1019
1019
|
const daemonPaths = (0, paths_1.resolveDaemonPaths)(process.env, homeOverride);
|
|
1020
1020
|
const logLevel = (0, logging_1.parseLogLevel)(takeFlagValue(args, "--log-level") ?? process.env.AGENTINBOX_LOG_LEVEL);
|
|
1021
1021
|
const logger = new logging_1.JsonLogger(logLevel, "agentinbox");
|
|
1022
|
+
// Socket transport: take the admission lock and publish the pid file before
|
|
1023
|
+
// opening the store, so concurrent starters and status checks see this
|
|
1024
|
+
// process during the (possibly slow) store open instead of racing it.
|
|
1025
|
+
let daemonLock = null;
|
|
1026
|
+
if (serveConfig.transport.kind === "socket") {
|
|
1027
|
+
daemonLock = (0, paths_1.daemonLockPath)(serveConfig.transport.socketPath);
|
|
1028
|
+
const acquisition = (0, daemon_1.acquireDaemonLock)(daemonLock);
|
|
1029
|
+
if (!acquisition.acquired) {
|
|
1030
|
+
const holderPid = acquisition.holder?.pid ?? null;
|
|
1031
|
+
logger.warn("daemon.already_running", {
|
|
1032
|
+
pid: holderPid,
|
|
1033
|
+
socketPath: serveConfig.transport.socketPath,
|
|
1034
|
+
lockPath: daemonLock,
|
|
1035
|
+
});
|
|
1036
|
+
console.error(`agentinbox daemon already running${holderPid != null ? ` (pid ${holderPid})` : ""}; not starting a second instance`);
|
|
1037
|
+
process.exit(0);
|
|
1038
|
+
}
|
|
1039
|
+
(0, daemon_1.writePidFile)(daemonPaths.pidPath, process.pid);
|
|
1040
|
+
}
|
|
1022
1041
|
const store = await store_1.AgentInboxStore.open(serveConfig.dbPath);
|
|
1023
1042
|
let service;
|
|
1024
1043
|
const adapters = new adapters_1.AdapterRegistry(store, async (input) => service.appendSourceEvent(input), {
|
|
@@ -1029,9 +1048,6 @@ async function runServe(args) {
|
|
|
1029
1048
|
await adapters.start();
|
|
1030
1049
|
await service.start();
|
|
1031
1050
|
const controlServer = await (0, control_server_1.startControlServer)(server, serveConfig.transport);
|
|
1032
|
-
if (serveConfig.transport.kind === "socket") {
|
|
1033
|
-
(0, daemon_1.writePidFile)(daemonPaths.pidPath, process.pid);
|
|
1034
|
-
}
|
|
1035
1051
|
(0, daemon_1.writeDaemonMetadata)(daemonPaths.metadataPath, { logLevel });
|
|
1036
1052
|
logger.info("daemon.ready", {
|
|
1037
1053
|
homeDir: serveConfig.homeDir,
|
|
@@ -1050,8 +1066,11 @@ async function runServe(args) {
|
|
|
1050
1066
|
void adapters.stop();
|
|
1051
1067
|
void service.stop();
|
|
1052
1068
|
store.close();
|
|
1053
|
-
if (serveConfig.transport.kind === "socket") {
|
|
1054
|
-
|
|
1069
|
+
if (serveConfig.transport.kind === "socket" && daemonLock != null) {
|
|
1070
|
+
// Ownership: only remove files that still belong to this process so a
|
|
1071
|
+
// replaced or rebound instance keeps its own pid file and socket.
|
|
1072
|
+
(0, daemon_1.removePidFileIfOwned)(daemonPaths.pidPath, process.pid);
|
|
1073
|
+
(0, daemon_1.releaseDaemonLock)(daemonLock, process.pid);
|
|
1055
1074
|
}
|
|
1056
1075
|
(0, daemon_1.removePidFile)(daemonPaths.metadataPath);
|
|
1057
1076
|
process.exit(0);
|
package/dist/src/daemon.js
CHANGED
|
@@ -3,12 +3,20 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.autostartDisabled = autostartDisabled;
|
|
6
7
|
exports.ensureDaemonForClient = ensureDaemonForClient;
|
|
7
8
|
exports.startDaemon = startDaemon;
|
|
8
9
|
exports.stopDaemon = stopDaemon;
|
|
9
10
|
exports.daemonStatus = daemonStatus;
|
|
10
11
|
exports.writePidFile = writePidFile;
|
|
11
12
|
exports.removePidFile = removePidFile;
|
|
13
|
+
exports.removePidFileIfOwned = removePidFileIfOwned;
|
|
14
|
+
exports.readDaemonLock = readDaemonLock;
|
|
15
|
+
exports.isLockHolderLive = isLockHolderLive;
|
|
16
|
+
exports.readLiveDaemonLockHolder = readLiveDaemonLockHolder;
|
|
17
|
+
exports.acquireDaemonLock = acquireDaemonLock;
|
|
18
|
+
exports.releaseDaemonLock = releaseDaemonLock;
|
|
19
|
+
exports.resolveStartTimeoutMs = resolveStartTimeoutMs;
|
|
12
20
|
exports.resolveDaemonLogLevel = resolveDaemonLogLevel;
|
|
13
21
|
exports.writeDaemonMetadata = writeDaemonMetadata;
|
|
14
22
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
@@ -17,11 +25,20 @@ const node_child_process_1 = require("node:child_process");
|
|
|
17
25
|
const client_1 = require("./client");
|
|
18
26
|
const paths_1 = require("./paths");
|
|
19
27
|
const logging_1 = require("./logging");
|
|
20
|
-
const
|
|
28
|
+
const util_1 = require("./util");
|
|
29
|
+
const DEFAULT_START_TIMEOUT_MS = 60_000;
|
|
30
|
+
// A freshly created but still-empty lock file may belong to a process between
|
|
31
|
+
// create and write; never steal it within this grace window.
|
|
32
|
+
const STALE_LOCK_GRACE_MS = 5_000;
|
|
21
33
|
const PACKAGE_VERSION = readOwnPackageVersion();
|
|
34
|
+
/** AGENTINBOX_NO_AUTOSTART disables implicit daemon spawning (systemd-style deployments). */
|
|
35
|
+
function autostartDisabled(env = process.env) {
|
|
36
|
+
return (0, util_1.isEnvFlagEnabled)(env.AGENTINBOX_NO_AUTOSTART);
|
|
37
|
+
}
|
|
22
38
|
async function ensureDaemonForClient(options = {}) {
|
|
39
|
+
const env = options.env ?? process.env;
|
|
23
40
|
const transport = resolveDaemonClientTransport(options);
|
|
24
|
-
if (options.noAutoStart || transport.kind !== "socket") {
|
|
41
|
+
if (options.noAutoStart || autostartDisabled(env) || transport.kind !== "socket") {
|
|
25
42
|
return transport;
|
|
26
43
|
}
|
|
27
44
|
if (await canReachHealthz(transport)) {
|
|
@@ -37,7 +54,6 @@ async function startDaemon(options = {}) {
|
|
|
37
54
|
const homeDir = (0, paths_1.resolveAgentInboxHome)(env, options.homeDirOverride);
|
|
38
55
|
const { pidPath, logPath } = (0, paths_1.resolveDaemonPaths)(env, options.homeDirOverride);
|
|
39
56
|
node_fs_1.default.mkdirSync(homeDir, { recursive: true });
|
|
40
|
-
cleanupStalePidFile(pidPath);
|
|
41
57
|
if (await canReachHealthz(transport)) {
|
|
42
58
|
const pid = readPidFile(pidPath);
|
|
43
59
|
return {
|
|
@@ -49,6 +65,31 @@ async function startDaemon(options = {}) {
|
|
|
49
65
|
transport,
|
|
50
66
|
};
|
|
51
67
|
}
|
|
68
|
+
// Admission: a live lock holder is starting (or serving) this socket — never
|
|
69
|
+
// spawn a competing daemon. This check is advisory; the spawned serve
|
|
70
|
+
// process enforces the lock authoritatively.
|
|
71
|
+
const lockPath = (0, paths_1.daemonLockPath)(transport.socketPath);
|
|
72
|
+
const timeoutMs = resolveStartTimeoutMs(env);
|
|
73
|
+
let pendingHolder = readLiveDaemonLockHolder(lockPath);
|
|
74
|
+
while (pendingHolder) {
|
|
75
|
+
const outcome = await waitForDaemonReady(transport, lockPath, timeoutMs);
|
|
76
|
+
if (outcome === "ready") {
|
|
77
|
+
const pid = readPidFile(pidPath) ?? pendingHolder.pid;
|
|
78
|
+
return {
|
|
79
|
+
started: false,
|
|
80
|
+
pid,
|
|
81
|
+
logLevel,
|
|
82
|
+
pidPath,
|
|
83
|
+
logPath,
|
|
84
|
+
transport,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (outcome === "timeout") {
|
|
88
|
+
throw new Error(`AgentInbox daemon is already starting (pid ${pendingHolder.pid}) but did not become ready within ${timeoutMs}ms`);
|
|
89
|
+
}
|
|
90
|
+
// The pending holder exited before becoming ready; re-check admission.
|
|
91
|
+
pendingHolder = readLiveDaemonLockHolder(lockPath);
|
|
92
|
+
}
|
|
52
93
|
const logFd = openLogFile(logPath);
|
|
53
94
|
let child;
|
|
54
95
|
try {
|
|
@@ -69,7 +110,7 @@ async function startDaemon(options = {}) {
|
|
|
69
110
|
node_fs_1.default.closeSync(logFd);
|
|
70
111
|
}
|
|
71
112
|
child.unref();
|
|
72
|
-
await waitForHealthz(transport,
|
|
113
|
+
await waitForHealthz(transport, timeoutMs);
|
|
73
114
|
return {
|
|
74
115
|
started: true,
|
|
75
116
|
pid: child.pid ?? -1,
|
|
@@ -83,42 +124,69 @@ async function stopDaemon(options = {}) {
|
|
|
83
124
|
const env = options.env ?? process.env;
|
|
84
125
|
const transport = requireSocketTransport(resolveDaemonClientTransport(options), "daemon stop");
|
|
85
126
|
const { pidPath, logPath, metadataPath } = (0, paths_1.resolveDaemonPaths)(env, options.homeDirOverride);
|
|
86
|
-
|
|
87
|
-
if (pid
|
|
127
|
+
let pid = readPidFile(pidPath);
|
|
128
|
+
if (pid == null || !(0, util_1.isPidAlive)(pid)) {
|
|
129
|
+
// The pid file may be stale or overwritten while the live daemon still
|
|
130
|
+
// owns the socket; fall back to the admission lock holder.
|
|
131
|
+
pid = readLiveDaemonLockHolder((0, paths_1.daemonLockPath)(transport.socketPath))?.pid ?? pid;
|
|
132
|
+
}
|
|
133
|
+
if (pid != null && (0, util_1.isPidAlive)(pid)) {
|
|
88
134
|
process.kill(pid, "SIGTERM");
|
|
89
135
|
await waitForProcessExit(pid, 3_000);
|
|
90
136
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
137
|
+
// Ownership: if an instance we did not stop still serves the socket, the
|
|
138
|
+
// files may now belong to it — leave them untouched. Otherwise remove the
|
|
139
|
+
// pid file only when it no longer references a live process.
|
|
140
|
+
if (!(await canReachHealthz(transport))) {
|
|
141
|
+
cleanupStalePidFile(pidPath);
|
|
142
|
+
cleanupFile(transport.socketPath);
|
|
143
|
+
cleanupFile(metadataPath);
|
|
144
|
+
}
|
|
94
145
|
return daemonStatus(options);
|
|
95
146
|
}
|
|
96
147
|
async function daemonStatus(options = {}) {
|
|
97
148
|
const env = options.env ?? process.env;
|
|
98
149
|
const transport = requireSocketTransport(resolveDaemonClientTransport(options), "daemon status");
|
|
99
150
|
const { pidPath, logPath, metadataPath } = (0, paths_1.resolveDaemonPaths)(env, options.homeDirOverride);
|
|
100
|
-
|
|
101
|
-
if (pid != null && !
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
151
|
+
let pid = readPidFile(pidPath);
|
|
152
|
+
if (pid != null && !(0, util_1.isPidAlive)(pid)) {
|
|
153
|
+
const holder = readLiveDaemonLockHolder((0, paths_1.daemonLockPath)(transport.socketPath));
|
|
154
|
+
if (holder != null) {
|
|
155
|
+
// The admission lock identifies the live daemon even when the pid file
|
|
156
|
+
// is stale (e.g. overwritten by a mixed-version writer).
|
|
157
|
+
pid = holder.pid;
|
|
158
|
+
}
|
|
159
|
+
else if (!(await canReachHealthz(transport))) {
|
|
160
|
+
// Nothing is serving and no live owner exists: clean stale files.
|
|
161
|
+
cleanupStalePidFile(pidPath);
|
|
162
|
+
cleanupFile(transport.socketPath);
|
|
163
|
+
cleanupFile(metadataPath);
|
|
164
|
+
return {
|
|
165
|
+
running: false,
|
|
166
|
+
starting: false,
|
|
167
|
+
pid: null,
|
|
168
|
+
logLevel: null,
|
|
169
|
+
version: null,
|
|
170
|
+
startedAt: null,
|
|
171
|
+
command: null,
|
|
172
|
+
nodeVersion: null,
|
|
173
|
+
pidPath,
|
|
174
|
+
logPath,
|
|
175
|
+
transport,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
// Something answers healthz (e.g. an older daemon without a lock);
|
|
180
|
+
// report it without owning its files.
|
|
181
|
+
pid = null;
|
|
182
|
+
}
|
|
117
183
|
}
|
|
118
184
|
const processInfo = pid == null ? null : readProcessMetadata(pid);
|
|
119
185
|
const daemonMetadata = readDaemonMetadata(metadataPath);
|
|
186
|
+
const running = await canReachHealthz(transport);
|
|
120
187
|
return {
|
|
121
|
-
running
|
|
188
|
+
running,
|
|
189
|
+
starting: !running && pid != null && (0, util_1.isPidAlive)(pid),
|
|
122
190
|
pid,
|
|
123
191
|
logLevel: daemonMetadata?.logLevel ?? null,
|
|
124
192
|
version: pid == null ? null : PACKAGE_VERSION,
|
|
@@ -137,6 +205,127 @@ function writePidFile(pidPath, pid) {
|
|
|
137
205
|
function removePidFile(pidPath) {
|
|
138
206
|
cleanupFile(pidPath);
|
|
139
207
|
}
|
|
208
|
+
/** Removes the pid file only when it still belongs to the given pid. */
|
|
209
|
+
function removePidFileIfOwned(pidPath, pid) {
|
|
210
|
+
if (readPidFile(pidPath) !== pid) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
cleanupFile(pidPath);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
function readDaemonLock(lockPath) {
|
|
217
|
+
try {
|
|
218
|
+
const parsed = JSON.parse(node_fs_1.default.readFileSync(lockPath, "utf8"));
|
|
219
|
+
if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
pid: parsed.pid,
|
|
224
|
+
processStartedAt: typeof parsed.processStartedAt === "string" ? parsed.processStartedAt : null,
|
|
225
|
+
acquiredAt: typeof parsed.acquiredAt === "string" ? parsed.acquiredAt : new Date(0).toISOString(),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function isLockHolderLive(holder) {
|
|
233
|
+
if (!(0, util_1.isPidAlive)(holder.pid)) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
if (holder.processStartedAt == null) {
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
const current = readProcessMetadata(holder.pid);
|
|
240
|
+
if (current?.startedAt == null) {
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
return current.startedAt === holder.processStartedAt;
|
|
244
|
+
}
|
|
245
|
+
function readLiveDaemonLockHolder(lockPath) {
|
|
246
|
+
const holder = readDaemonLock(lockPath);
|
|
247
|
+
return holder != null && isLockHolderLive(holder) ? holder : null;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Atomically (O_EXCL) acquires the daemon admission lock for this process.
|
|
251
|
+
* A stale lock (dead pid or pid reuse) is removed and retried once. A freshly
|
|
252
|
+
* created but still-empty lock is treated as held to avoid racing a concurrent
|
|
253
|
+
* creator between create and write.
|
|
254
|
+
*/
|
|
255
|
+
function acquireDaemonLock(lockPath) {
|
|
256
|
+
let holder = null;
|
|
257
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
258
|
+
holder = null;
|
|
259
|
+
try {
|
|
260
|
+
const metadata = readProcessMetadata(process.pid);
|
|
261
|
+
const info = {
|
|
262
|
+
pid: process.pid,
|
|
263
|
+
processStartedAt: metadata?.startedAt ?? null,
|
|
264
|
+
acquiredAt: new Date().toISOString(),
|
|
265
|
+
};
|
|
266
|
+
node_fs_1.default.writeFileSync(lockPath, `${JSON.stringify(info)}\n`, { encoding: "utf8", flag: "wx" });
|
|
267
|
+
return { acquired: true, lockPath, holder: null };
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
if (!isFileExistsError(error)) {
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
holder = readDaemonLock(lockPath);
|
|
275
|
+
if (holder != null && isLockHolderLive(holder)) {
|
|
276
|
+
return { acquired: false, lockPath, holder };
|
|
277
|
+
}
|
|
278
|
+
if (holder == null && isLockFileFresh(lockPath)) {
|
|
279
|
+
return { acquired: false, lockPath, holder: null };
|
|
280
|
+
}
|
|
281
|
+
cleanupFile(lockPath);
|
|
282
|
+
}
|
|
283
|
+
return { acquired: false, lockPath, holder };
|
|
284
|
+
}
|
|
285
|
+
/** Releases the lock only when it still belongs to the given pid. */
|
|
286
|
+
function releaseDaemonLock(lockPath, pid) {
|
|
287
|
+
const holder = readDaemonLock(lockPath);
|
|
288
|
+
if (holder?.pid === pid) {
|
|
289
|
+
cleanupFile(lockPath);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function resolveStartTimeoutMs(env = process.env) {
|
|
293
|
+
const raw = env.AGENTINBOX_START_TIMEOUT_MS;
|
|
294
|
+
if (raw == null || raw.trim() === "") {
|
|
295
|
+
return DEFAULT_START_TIMEOUT_MS;
|
|
296
|
+
}
|
|
297
|
+
const parsed = Number.parseInt(raw, 10);
|
|
298
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_START_TIMEOUT_MS;
|
|
299
|
+
}
|
|
300
|
+
function isFileExistsError(error) {
|
|
301
|
+
return typeof error === "object" && error != null && error.code === "EEXIST";
|
|
302
|
+
}
|
|
303
|
+
function isLockFileFresh(lockPath) {
|
|
304
|
+
try {
|
|
305
|
+
return Date.now() - node_fs_1.default.statSync(lockPath).mtimeMs < STALE_LOCK_GRACE_MS;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Waits for the daemon holding the lock to serve healthz. Returns "lost" when
|
|
313
|
+
* the holder disappears without becoming ready so the caller can re-check
|
|
314
|
+
* admission instead of timing out.
|
|
315
|
+
*/
|
|
316
|
+
async function waitForDaemonReady(transport, lockPath, timeoutMs) {
|
|
317
|
+
const startedAt = Date.now();
|
|
318
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
319
|
+
if (await canReachHealthz(transport)) {
|
|
320
|
+
return "ready";
|
|
321
|
+
}
|
|
322
|
+
if (readLiveDaemonLockHolder(lockPath) == null) {
|
|
323
|
+
return "lost";
|
|
324
|
+
}
|
|
325
|
+
await sleep(100);
|
|
326
|
+
}
|
|
327
|
+
return "timeout";
|
|
328
|
+
}
|
|
140
329
|
function daemonChildArgs() {
|
|
141
330
|
const execArgv = [...process.execArgv];
|
|
142
331
|
return [...execArgv, process.argv[1], "serve"];
|
|
@@ -232,20 +421,20 @@ async function waitForHealthz(transport, timeoutMs) {
|
|
|
232
421
|
async function waitForProcessExit(pid, timeoutMs) {
|
|
233
422
|
const startedAt = Date.now();
|
|
234
423
|
while (Date.now() - startedAt < timeoutMs) {
|
|
235
|
-
if (!
|
|
424
|
+
if (!(0, util_1.isPidAlive)(pid)) {
|
|
236
425
|
return;
|
|
237
426
|
}
|
|
238
427
|
await sleep(100);
|
|
239
428
|
}
|
|
240
429
|
throw new Error(`timed out waiting for process ${pid} to exit`);
|
|
241
430
|
}
|
|
242
|
-
function cleanupStalePidFile(pidPath
|
|
431
|
+
function cleanupStalePidFile(pidPath) {
|
|
243
432
|
const pid = readPidFile(pidPath);
|
|
244
433
|
if (pid == null) {
|
|
245
434
|
cleanupFile(pidPath);
|
|
246
435
|
return;
|
|
247
436
|
}
|
|
248
|
-
if (
|
|
437
|
+
if (!(0, util_1.isPidAlive)(pid)) {
|
|
249
438
|
cleanupFile(pidPath);
|
|
250
439
|
}
|
|
251
440
|
}
|
|
@@ -325,15 +514,6 @@ function inferNodeVersionFromCommand(command) {
|
|
|
325
514
|
const match = command.match(/node\/(v\d+\.\d+\.\d+)\//);
|
|
326
515
|
return match ? match[1] : null;
|
|
327
516
|
}
|
|
328
|
-
function isProcessAlive(pid) {
|
|
329
|
-
try {
|
|
330
|
-
process.kill(pid, 0);
|
|
331
|
-
return true;
|
|
332
|
-
}
|
|
333
|
-
catch {
|
|
334
|
-
return false;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
517
|
function cleanupFile(filePath) {
|
|
338
518
|
try {
|
|
339
519
|
if (node_fs_1.default.existsSync(filePath)) {
|
package/dist/src/paths.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.DEFAULT_AGENTINBOX_PORT = void 0;
|
|
|
7
7
|
exports.resolveAgentInboxHome = resolveAgentInboxHome;
|
|
8
8
|
exports.resolveServeConfig = resolveServeConfig;
|
|
9
9
|
exports.resolveDaemonPaths = resolveDaemonPaths;
|
|
10
|
+
exports.daemonLockPath = daemonLockPath;
|
|
10
11
|
exports.resolveClientTransport = resolveClientTransport;
|
|
11
12
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
12
13
|
const node_os_1 = __importDefault(require("node:os"));
|
|
@@ -74,6 +75,13 @@ function resolveDaemonPaths(env = process.env, homeDirOverride) {
|
|
|
74
75
|
metadataPath: node_path_1.default.join(homeDir, "agentinbox.daemon.json"),
|
|
75
76
|
};
|
|
76
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Admission lock for the daemon serving a given socket. Scoped to the socket
|
|
80
|
+
* identity (not the home dir) so an explicit --socket gets its own lock.
|
|
81
|
+
*/
|
|
82
|
+
function daemonLockPath(socketPath) {
|
|
83
|
+
return `${socketPath}.lock`;
|
|
84
|
+
}
|
|
77
85
|
function resolveClientTransport(input = {}) {
|
|
78
86
|
const env = input.env ?? process.env;
|
|
79
87
|
const homeDir = resolveAgentInboxHome(env, input.homeDirOverride);
|
package/dist/src/service.js
CHANGED
|
@@ -2243,9 +2243,15 @@ class AgentInboxService {
|
|
|
2243
2243
|
return;
|
|
2244
2244
|
}
|
|
2245
2245
|
const state = this.store.getActivationDispatchState(buffer.agentId, buffer.targetId);
|
|
2246
|
+
// Within an active notify lease, buffered entries merge into the pending
|
|
2247
|
+
// set (below) instead of re-dispatching. Webhook targets used to
|
|
2248
|
+
// re-dispatch every flush window during the lease, which queued one
|
|
2249
|
+
// wake per window downstream and produced empty wake turns when the
|
|
2250
|
+
// consuming turn acked everything (issue #233). Merge semantics now
|
|
2251
|
+
// match terminal targets: re-dispatch happens on ack (when pending new
|
|
2252
|
+
// items exist) or lease expiry.
|
|
2246
2253
|
const shouldAttemptDispatch = !state
|
|
2247
|
-
|| (state.status === "dirty" && state.leaseExpiresAt == null)
|
|
2248
|
-
|| (state.status === "notified" && buffer.targetKind === "webhook");
|
|
2254
|
+
|| (state.status === "dirty" && state.leaseExpiresAt == null);
|
|
2249
2255
|
if (shouldAttemptDispatch) {
|
|
2250
2256
|
const pendingState = state?.status === "dirty" ? state : null;
|
|
2251
2257
|
const inbox = this.ensureInboxForAgent(buffer.agentId);
|
|
@@ -2427,6 +2433,13 @@ class AgentInboxService {
|
|
|
2427
2433
|
if (reason === "ack" && state.status !== "dirty") {
|
|
2428
2434
|
return;
|
|
2429
2435
|
}
|
|
2436
|
+
if (reason === "ack" && target.kind === "webhook" && state.pendingNewItemCount <= 0) {
|
|
2437
|
+
// Ack-driven re-dispatch for webhook targets requires items that no
|
|
2438
|
+
// previous notification covered. Without new information a POST would
|
|
2439
|
+
// only wake the runtime about entries it already knows. Terminal
|
|
2440
|
+
// targets keep ack-driven re-render behavior.
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2430
2443
|
if (reason === "lease" && state.status === "notified") {
|
|
2431
2444
|
// No new items since last notification — just renew lease for both
|
|
2432
2445
|
// terminal and webhook targets. Re-dispatching would cause an infinite
|
package/dist/src/store.js
CHANGED
|
@@ -35,8 +35,10 @@ class AgentInboxStore {
|
|
|
35
35
|
this.dbPath = dbPath;
|
|
36
36
|
this.db = db;
|
|
37
37
|
}
|
|
38
|
-
static async open(dbPath) {
|
|
38
|
+
static async open(dbPath, options = {}) {
|
|
39
|
+
const env = options.env ?? process.env;
|
|
39
40
|
node_fs_1.default.mkdirSync(node_path_1.default.dirname(dbPath), { recursive: true });
|
|
41
|
+
removeOrphanBackupTmps(dbPath);
|
|
40
42
|
const existedBeforeOpen = node_fs_1.default.existsSync(dbPath);
|
|
41
43
|
let db = await this.openDatabaseWithRecovery(dbPath);
|
|
42
44
|
let store = new AgentInboxStore(dbPath, db);
|
|
@@ -46,7 +48,7 @@ class AgentInboxStore {
|
|
|
46
48
|
db = this.openDatabase(dbPath);
|
|
47
49
|
store = new AgentInboxStore(dbPath, db);
|
|
48
50
|
}
|
|
49
|
-
else if (existedBeforeOpen) {
|
|
51
|
+
else if (existedBeforeOpen && startupBackupEnabled(env)) {
|
|
50
52
|
await store.backupHealthyDatabase();
|
|
51
53
|
}
|
|
52
54
|
store.migrate();
|
|
@@ -2382,3 +2384,40 @@ function uniqueSorted(values) {
|
|
|
2382
2384
|
function summarizeBackfilledItemEntry(item) {
|
|
2383
2385
|
return `${item.eventVariant} from ${item.sourceId}`;
|
|
2384
2386
|
}
|
|
2387
|
+
function startupBackupEnabled(env) {
|
|
2388
|
+
return !(0, util_1.isEnvFlagDisabled)(env.AGENTINBOX_STARTUP_BACKUP);
|
|
2389
|
+
}
|
|
2390
|
+
/**
|
|
2391
|
+
* Removes leftover `<db>.bak.<pid>.tmp` files whose owning process is dead.
|
|
2392
|
+
* Tmp files owned by a live pid belong to a concurrent open and are preserved.
|
|
2393
|
+
*/
|
|
2394
|
+
function removeOrphanBackupTmps(dbPath) {
|
|
2395
|
+
const dir = node_path_1.default.dirname(dbPath);
|
|
2396
|
+
const pattern = new RegExp(`^${escapeRegExp(node_path_1.default.basename(dbPath))}\\.bak\\.(\\d+)\\.tmp$`);
|
|
2397
|
+
let entries;
|
|
2398
|
+
try {
|
|
2399
|
+
entries = node_fs_1.default.readdirSync(dir);
|
|
2400
|
+
}
|
|
2401
|
+
catch {
|
|
2402
|
+
return;
|
|
2403
|
+
}
|
|
2404
|
+
for (const name of entries) {
|
|
2405
|
+
const match = pattern.exec(name);
|
|
2406
|
+
if (!match) {
|
|
2407
|
+
continue;
|
|
2408
|
+
}
|
|
2409
|
+
const pid = Number.parseInt(match[1], 10);
|
|
2410
|
+
if (Number.isInteger(pid) && pid > 0 && (0, util_1.isPidAlive)(pid)) {
|
|
2411
|
+
continue;
|
|
2412
|
+
}
|
|
2413
|
+
try {
|
|
2414
|
+
node_fs_1.default.rmSync(node_path_1.default.join(dir, name), { force: true });
|
|
2415
|
+
}
|
|
2416
|
+
catch {
|
|
2417
|
+
// Best-effort GC.
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
function escapeRegExp(value) {
|
|
2422
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2423
|
+
}
|
package/dist/src/util.js
CHANGED
|
@@ -4,6 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.nowIso = nowIso;
|
|
7
|
+
exports.isPidAlive = isPidAlive;
|
|
8
|
+
exports.isEnvFlagDisabled = isEnvFlagDisabled;
|
|
9
|
+
exports.isEnvFlagEnabled = isEnvFlagEnabled;
|
|
7
10
|
exports.generateCanonicalId = generateCanonicalId;
|
|
8
11
|
exports.generateId = generateId;
|
|
9
12
|
exports.generateShortToken = generateShortToken;
|
|
@@ -21,6 +24,32 @@ const ENTRY_THREAD_ID_PATTERN = new RegExp(`^(ent|thr)_[${CANONICAL_ID_ALPHABET}
|
|
|
21
24
|
function nowIso() {
|
|
22
25
|
return new Date().toISOString();
|
|
23
26
|
}
|
|
27
|
+
function isPidAlive(pid) {
|
|
28
|
+
try {
|
|
29
|
+
process.kill(pid, 0);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Treats an env flag as disabled only when it holds an explicit falsy word.
|
|
38
|
+
* Missing or empty values mean "unset" and keep the default behavior.
|
|
39
|
+
*/
|
|
40
|
+
function isEnvFlagDisabled(raw) {
|
|
41
|
+
if (raw == null) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
const normalized = raw.trim().toLowerCase();
|
|
45
|
+
return normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off";
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Treats an env flag as set only when it holds a non-empty, non-falsy value.
|
|
49
|
+
*/
|
|
50
|
+
function isEnvFlagEnabled(raw) {
|
|
51
|
+
return raw != null && raw.trim() !== "" && !isEnvFlagDisabled(raw);
|
|
52
|
+
}
|
|
24
53
|
function generateCanonicalId(prefix, length = CANONICAL_ID_TOKEN_LENGTH) {
|
|
25
54
|
return `${prefix}_${generateShortToken(length)}`;
|
|
26
55
|
}
|