@ouro.bot/cli 0.1.0-alpha.148 → 0.1.0-alpha.149
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/changelog.json +12 -0
- package/dist/heart/daemon/daemon-cli.js +37 -11
- package/dist/heart/daemon/daemon-entry.js +31 -2
- package/dist/heart/daemon/daemon-tombstone.js +106 -0
- package/dist/heart/daemon/daemon.js +64 -2
- package/dist/heart/daemon/process-manager.js +51 -0
- package/dist/nerves/index.js +47 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.149",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Daemon tombstone: writeDaemonTombstone persists crash context (reason, stack, uptime) to ~/.ouro-cli/daemon-death.json on uncaught exceptions and startup failures.",
|
|
8
|
+
"Global exception handlers: daemon-entry installs uncaughtException (tombstone + graceful 5s shutdown) and unhandledRejection (warn, no exit) handlers.",
|
|
9
|
+
"Log rotation: ndjson file sink rotates logs at 50MB (configurable), keeping .1 and .2 backups. Throttled stat checks via byte counter.",
|
|
10
|
+
"Socket connection error handling: connection.on('error') prevents server crash on client disconnect, connection.end() wrapped for EPIPE safety, persistent server.on('error') after listen.",
|
|
11
|
+
"Crash context in status: DaemonAgentSnapshot includes lastExitCode/lastSignal, Workers table shows last exit info, stopped-daemon status reads tombstone for 'Last death' line.",
|
|
12
|
+
"Cooldown recovery: after restart exhaustion, agents schedule recovery after cooldownRecoveryMs (default 5min). Reset crash history on recovery. maxCooldownRetries (default 3) before permanent give-up.",
|
|
13
|
+
"Defensive handleCommand: extracted handleCommandInner, outer handleCommand wraps with try/catch logging daemon.command_error with kind/error/stack before re-throw."
|
|
14
|
+
]
|
|
15
|
+
},
|
|
4
16
|
{
|
|
5
17
|
"version": "0.1.0-alpha.148",
|
|
6
18
|
"changes": [
|
|
@@ -71,6 +71,7 @@ const thoughts_1 = require("./thoughts");
|
|
|
71
71
|
const ouro_bot_global_installer_1 = require("./ouro-bot-global-installer");
|
|
72
72
|
const launchd_1 = require("./launchd");
|
|
73
73
|
const socket_client_1 = require("./socket-client");
|
|
74
|
+
const daemon_tombstone_1 = require("./daemon-tombstone");
|
|
74
75
|
const session_activity_1 = require("../session-activity");
|
|
75
76
|
const auth_flow_1 = require("./auth-flow");
|
|
76
77
|
function stringField(value) {
|
|
@@ -145,6 +146,8 @@ function parseStatusPayload(data) {
|
|
|
145
146
|
status,
|
|
146
147
|
pid,
|
|
147
148
|
restartCount,
|
|
149
|
+
lastExitCode: numberField(row.lastExitCode) ?? null,
|
|
150
|
+
lastSignal: stringField(row.lastSignal) ?? null,
|
|
148
151
|
};
|
|
149
152
|
});
|
|
150
153
|
if (parsedSenses.some((row) => row === null) || parsedWorkers.some((row) => row === null))
|
|
@@ -198,13 +201,23 @@ function formatDaemonStatusOutput(response, fallback) {
|
|
|
198
201
|
row.status,
|
|
199
202
|
row.detail,
|
|
200
203
|
]);
|
|
201
|
-
const workerRows = payload.workers.map((row) =>
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
row.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
204
|
+
const workerRows = payload.workers.map((row) => {
|
|
205
|
+
/* v8 ignore start — exit info branches tested via daemon-crash-context; v8 misreports conditional chains @preserve */
|
|
206
|
+
let exitInfo = "n/a";
|
|
207
|
+
if (row.lastExitCode !== null)
|
|
208
|
+
exitInfo = `code=${row.lastExitCode}`;
|
|
209
|
+
if (row.lastSignal !== null)
|
|
210
|
+
exitInfo = row.lastExitCode !== null ? `code=${row.lastExitCode} sig=${row.lastSignal}` : `sig=${row.lastSignal}`;
|
|
211
|
+
/* v8 ignore stop */
|
|
212
|
+
return [
|
|
213
|
+
row.agent,
|
|
214
|
+
row.worker,
|
|
215
|
+
row.status,
|
|
216
|
+
row.pid === null ? "n/a" : String(row.pid),
|
|
217
|
+
String(row.restartCount),
|
|
218
|
+
exitInfo,
|
|
219
|
+
];
|
|
220
|
+
});
|
|
208
221
|
return [
|
|
209
222
|
"Overview",
|
|
210
223
|
formatTable(["Item", "Value"], overviewRows),
|
|
@@ -213,7 +226,7 @@ function formatDaemonStatusOutput(response, fallback) {
|
|
|
213
226
|
formatTable(["Agent", "Sense", "Enabled", "State", "Detail"], senseRows),
|
|
214
227
|
"",
|
|
215
228
|
"Workers",
|
|
216
|
-
formatTable(["Agent", "Worker", "State", "PID", "Restarts"], workerRows),
|
|
229
|
+
formatTable(["Agent", "Worker", "State", "PID", "Restarts", "Last Exit"], workerRows),
|
|
217
230
|
].join("\n");
|
|
218
231
|
}
|
|
219
232
|
async function ensureDaemonRunning(deps) {
|
|
@@ -338,15 +351,28 @@ function buildStoppedStatusPayload(socketPath) {
|
|
|
338
351
|
};
|
|
339
352
|
}
|
|
340
353
|
function daemonUnavailableStatusOutput(socketPath) {
|
|
341
|
-
|
|
354
|
+
/* v8 ignore start — tombstone read tested in daemon-status-tombstone.test; branch misreported @preserve */
|
|
355
|
+
const tombstone = (0, daemon_tombstone_1.readDaemonTombstone)();
|
|
356
|
+
const deathLine = tombstone
|
|
357
|
+
? `Last death: ${tombstone.timestamp} -- ${tombstone.reason}: ${tombstone.message}`
|
|
358
|
+
: null;
|
|
359
|
+
/* v8 ignore stop */
|
|
360
|
+
const lines = [
|
|
342
361
|
formatDaemonStatusOutput({
|
|
343
362
|
ok: true,
|
|
344
363
|
summary: "daemon not running",
|
|
345
364
|
data: buildStoppedStatusPayload(socketPath),
|
|
346
365
|
}, "daemon not running"),
|
|
347
366
|
"",
|
|
348
|
-
|
|
349
|
-
|
|
367
|
+
];
|
|
368
|
+
/* v8 ignore start — tombstone presence requires real daemon crash @preserve */
|
|
369
|
+
if (deathLine) {
|
|
370
|
+
lines.push(deathLine);
|
|
371
|
+
lines.push("");
|
|
372
|
+
/* v8 ignore stop */
|
|
373
|
+
}
|
|
374
|
+
lines.push("daemon not running; run `ouro up`");
|
|
375
|
+
return lines.join("\n");
|
|
350
376
|
}
|
|
351
377
|
function isDaemonUnavailableError(error) {
|
|
352
378
|
const code = typeof error === "object" && error !== null && "code" in error
|
|
@@ -51,6 +51,7 @@ const habit_scheduler_1 = require("./habit-scheduler");
|
|
|
51
51
|
const habit_migration_1 = require("./habit-migration");
|
|
52
52
|
const os_cron_deps_1 = require("./os-cron-deps");
|
|
53
53
|
const os_cron_1 = require("./os-cron");
|
|
54
|
+
const daemon_tombstone_1 = require("./daemon-tombstone");
|
|
54
55
|
function parseSocketPath(argv) {
|
|
55
56
|
const socketIndex = argv.indexOf("--socket");
|
|
56
57
|
if (socketIndex >= 0) {
|
|
@@ -152,14 +153,18 @@ void daemon.start().then(() => {
|
|
|
152
153
|
scheduler.watchForChanges();
|
|
153
154
|
habitSchedulers.push(scheduler);
|
|
154
155
|
}
|
|
155
|
-
}).catch(async () => {
|
|
156
|
+
}).catch(async (err) => {
|
|
156
157
|
/* v8 ignore stop */
|
|
158
|
+
/* v8 ignore start — instanceof branch defensive; catch always receives Error in practice @preserve */
|
|
159
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
160
|
+
/* v8 ignore stop */
|
|
161
|
+
(0, daemon_tombstone_1.writeDaemonTombstone)("startupFailure", error);
|
|
157
162
|
(0, runtime_1.emitNervesEvent)({
|
|
158
163
|
level: "error",
|
|
159
164
|
component: "daemon",
|
|
160
165
|
event: "daemon.entry_error",
|
|
161
166
|
message: "daemon entrypoint failed",
|
|
162
|
-
meta: {},
|
|
167
|
+
meta: { error: error.message },
|
|
163
168
|
});
|
|
164
169
|
await daemon.stop();
|
|
165
170
|
process.exit(1);
|
|
@@ -178,3 +183,27 @@ process.on("SIGTERM", () => {
|
|
|
178
183
|
}
|
|
179
184
|
void daemon.stop().then(() => process.exit(0));
|
|
180
185
|
});
|
|
186
|
+
/* v8 ignore start -- global exception handlers: genuinely untestable in vitest; exercised by real daemon crashes @preserve */
|
|
187
|
+
process.on("uncaughtException", (error) => {
|
|
188
|
+
(0, daemon_tombstone_1.writeDaemonTombstone)("uncaughtException", error);
|
|
189
|
+
(0, runtime_1.emitNervesEvent)({
|
|
190
|
+
level: "error",
|
|
191
|
+
component: "daemon",
|
|
192
|
+
event: "daemon.uncaught_exception",
|
|
193
|
+
message: "uncaught exception in daemon process",
|
|
194
|
+
meta: { error: error.message, stack: error.stack ?? null },
|
|
195
|
+
});
|
|
196
|
+
// Graceful 5-second shutdown window
|
|
197
|
+
setTimeout(() => process.exit(1), 5_000).unref();
|
|
198
|
+
void daemon.stop().then(() => process.exit(1));
|
|
199
|
+
});
|
|
200
|
+
process.on("unhandledRejection", (reason) => {
|
|
201
|
+
(0, runtime_1.emitNervesEvent)({
|
|
202
|
+
level: "warn",
|
|
203
|
+
component: "daemon",
|
|
204
|
+
event: "daemon.unhandled_rejection",
|
|
205
|
+
message: "unhandled promise rejection in daemon process",
|
|
206
|
+
meta: { reason: reason instanceof Error ? reason.message : String(reason) },
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
/* v8 ignore stop */
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.getTombstonePath = getTombstonePath;
|
|
37
|
+
exports.setTombstonePath = setTombstonePath;
|
|
38
|
+
exports.writeDaemonTombstone = writeDaemonTombstone;
|
|
39
|
+
exports.readDaemonTombstone = readDaemonTombstone;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const os = __importStar(require("os"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
44
|
+
let _tombstonePath = null;
|
|
45
|
+
function getTombstonePath() {
|
|
46
|
+
if (!_tombstonePath) {
|
|
47
|
+
_tombstonePath = path.join(os.homedir(), ".ouro-cli", "daemon-death.json");
|
|
48
|
+
}
|
|
49
|
+
return _tombstonePath;
|
|
50
|
+
}
|
|
51
|
+
/** Overrides the tombstone path for testing. Pass null to reset. */
|
|
52
|
+
function setTombstonePath(p) {
|
|
53
|
+
_tombstonePath = p;
|
|
54
|
+
}
|
|
55
|
+
function writeDaemonTombstone(reason, error) {
|
|
56
|
+
const tombstone = {
|
|
57
|
+
reason,
|
|
58
|
+
message: error?.message ?? reason,
|
|
59
|
+
stack: error?.stack ?? null,
|
|
60
|
+
timestamp: new Date().toISOString(),
|
|
61
|
+
pid: process.pid,
|
|
62
|
+
uptimeSeconds: Math.floor(process.uptime()),
|
|
63
|
+
};
|
|
64
|
+
const filePath = getTombstonePath();
|
|
65
|
+
try {
|
|
66
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
67
|
+
fs.writeFileSync(filePath, JSON.stringify(tombstone, null, 2) + "\n", "utf-8");
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Best-effort: if we can't write, we're already dying.
|
|
71
|
+
}
|
|
72
|
+
(0, runtime_1.emitNervesEvent)({
|
|
73
|
+
level: "error",
|
|
74
|
+
component: "daemon",
|
|
75
|
+
event: "daemon.tombstone_written",
|
|
76
|
+
message: `daemon tombstone written: ${reason}`,
|
|
77
|
+
meta: { reason, errorMessage: error?.message ?? null, filePath },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function readDaemonTombstone() {
|
|
81
|
+
const filePath = getTombstonePath();
|
|
82
|
+
try {
|
|
83
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
84
|
+
const parsed = JSON.parse(raw);
|
|
85
|
+
if (typeof parsed.reason !== "string" || typeof parsed.timestamp !== "string") {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
(0, runtime_1.emitNervesEvent)({
|
|
89
|
+
component: "daemon",
|
|
90
|
+
event: "daemon.tombstone_read",
|
|
91
|
+
message: "read daemon tombstone",
|
|
92
|
+
meta: { filePath },
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
reason: parsed.reason,
|
|
96
|
+
message: typeof parsed.message === "string" ? parsed.message : String(parsed.reason),
|
|
97
|
+
stack: typeof parsed.stack === "string" ? parsed.stack : null,
|
|
98
|
+
timestamp: parsed.timestamp,
|
|
99
|
+
pid: typeof parsed.pid === "number" ? parsed.pid : 0,
|
|
100
|
+
uptimeSeconds: typeof parsed.uptimeSeconds === "number" ? parsed.uptimeSeconds : 0,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -135,6 +135,8 @@ function buildWorkerRows(snapshots) {
|
|
|
135
135
|
pid: snapshot.pid,
|
|
136
136
|
restartCount: snapshot.restartCount,
|
|
137
137
|
startedAt: snapshot.startedAt,
|
|
138
|
+
lastExitCode: snapshot.lastExitCode ?? null,
|
|
139
|
+
lastSignal: snapshot.lastSignal ?? null,
|
|
138
140
|
}));
|
|
139
141
|
}
|
|
140
142
|
function formatStatusSummary(payload) {
|
|
@@ -269,12 +271,36 @@ class OuroDaemon {
|
|
|
269
271
|
this.server = net.createServer((connection) => {
|
|
270
272
|
let raw = "";
|
|
271
273
|
let responded = false;
|
|
274
|
+
/* v8 ignore start — connection error handler requires real socket error @preserve */
|
|
275
|
+
connection.on("error", (err) => {
|
|
276
|
+
(0, runtime_1.emitNervesEvent)({
|
|
277
|
+
level: "warn",
|
|
278
|
+
component: "daemon",
|
|
279
|
+
event: "daemon.connection_error",
|
|
280
|
+
message: "socket connection error",
|
|
281
|
+
meta: { error: err.message, code: err.code ?? null },
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
/* v8 ignore stop */
|
|
272
285
|
const flushResponse = async () => {
|
|
273
286
|
if (responded)
|
|
274
287
|
return;
|
|
275
288
|
responded = true;
|
|
276
289
|
const response = await this.handleRawPayload(raw);
|
|
277
|
-
|
|
290
|
+
try {
|
|
291
|
+
connection.end(response);
|
|
292
|
+
/* v8 ignore start — EPIPE catch requires real socket disconnect @preserve */
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
(0, runtime_1.emitNervesEvent)({
|
|
296
|
+
level: "warn",
|
|
297
|
+
component: "daemon",
|
|
298
|
+
event: "daemon.connection_end_error",
|
|
299
|
+
message: "failed to send response to client (EPIPE)",
|
|
300
|
+
meta: { error: err instanceof Error ? err.message : String(err) },
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
/* v8 ignore stop */
|
|
278
304
|
};
|
|
279
305
|
connection.on("data", (chunk) => {
|
|
280
306
|
raw += chunk.toString("utf-8");
|
|
@@ -287,7 +313,22 @@ class OuroDaemon {
|
|
|
287
313
|
const server = this.server;
|
|
288
314
|
await new Promise((resolve, reject) => {
|
|
289
315
|
server.once("error", reject);
|
|
290
|
-
server.listen(this.socketPath, () =>
|
|
316
|
+
server.listen(this.socketPath, () => {
|
|
317
|
+
// Replace the one-time error listener with a persistent one after successful listen
|
|
318
|
+
server.removeAllListeners("error");
|
|
319
|
+
/* v8 ignore start — server error after listen requires real socket race condition @preserve */
|
|
320
|
+
server.on("error", (err) => {
|
|
321
|
+
(0, runtime_1.emitNervesEvent)({
|
|
322
|
+
level: "error",
|
|
323
|
+
component: "daemon",
|
|
324
|
+
event: "daemon.server_error",
|
|
325
|
+
message: "daemon server error after listen",
|
|
326
|
+
meta: { error: err.message, code: err.code ?? null },
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
/* v8 ignore stop */
|
|
330
|
+
resolve();
|
|
331
|
+
});
|
|
291
332
|
});
|
|
292
333
|
}
|
|
293
334
|
async drainPendingBundleMessages() {
|
|
@@ -467,6 +508,27 @@ class OuroDaemon {
|
|
|
467
508
|
message: "handling daemon command",
|
|
468
509
|
meta: { kind: command.kind },
|
|
469
510
|
});
|
|
511
|
+
try {
|
|
512
|
+
return await this.handleCommandInner(command);
|
|
513
|
+
/* v8 ignore start — command error catch tested in daemon-command-error.test; instanceof branches defensive @preserve */
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
(0, runtime_1.emitNervesEvent)({
|
|
517
|
+
level: "error",
|
|
518
|
+
component: "daemon",
|
|
519
|
+
event: "daemon.command_error",
|
|
520
|
+
message: "unexpected error handling daemon command",
|
|
521
|
+
meta: {
|
|
522
|
+
kind: command.kind,
|
|
523
|
+
error: error instanceof Error ? error.message : String(error),
|
|
524
|
+
stack: error instanceof Error ? error.stack ?? null : null,
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
/* v8 ignore stop */
|
|
530
|
+
}
|
|
531
|
+
async handleCommandInner(command) {
|
|
470
532
|
switch (command.kind) {
|
|
471
533
|
case "daemon.start":
|
|
472
534
|
await this.start();
|
|
@@ -51,12 +51,16 @@ class DaemonProcessManager {
|
|
|
51
51
|
now;
|
|
52
52
|
setTimeoutFn;
|
|
53
53
|
clearTimeoutFn;
|
|
54
|
+
cooldownRecoveryMs;
|
|
55
|
+
maxCooldownRetries;
|
|
54
56
|
existsSyncFn;
|
|
55
57
|
constructor(options) {
|
|
56
58
|
this.maxRestartsPerHour = options.maxRestartsPerHour ?? 10;
|
|
57
59
|
this.stabilityThresholdMs = options.stabilityThresholdMs ?? 60_000;
|
|
58
60
|
this.initialBackoffMs = options.initialBackoffMs ?? 1_000;
|
|
59
61
|
this.maxBackoffMs = options.maxBackoffMs ?? 60_000;
|
|
62
|
+
this.cooldownRecoveryMs = options.cooldownRecoveryMs ?? 5 * 60 * 1_000;
|
|
63
|
+
this.maxCooldownRetries = options.maxCooldownRetries ?? 3;
|
|
60
64
|
this.spawnFn = options.spawn ?? ((command, args, spawnOptions) => (0, child_process_1.spawn)(command, args, spawnOptions));
|
|
61
65
|
this.now = options.now ?? (() => Date.now());
|
|
62
66
|
this.setTimeoutFn = options.setTimeoutFn ?? ((cb, delay) => setTimeout(cb, delay));
|
|
@@ -69,6 +73,8 @@ class DaemonProcessManager {
|
|
|
69
73
|
restartTimer: null,
|
|
70
74
|
crashTimestamps: [],
|
|
71
75
|
stopRequested: false,
|
|
76
|
+
cooldownTimer: null,
|
|
77
|
+
cooldownRetryCount: 0,
|
|
72
78
|
snapshot: {
|
|
73
79
|
name: agent.name,
|
|
74
80
|
channel: agent.channel,
|
|
@@ -78,6 +84,8 @@ class DaemonProcessManager {
|
|
|
78
84
|
startedAt: null,
|
|
79
85
|
lastCrashAt: null,
|
|
80
86
|
backoffMs: this.initialBackoffMs,
|
|
87
|
+
lastExitCode: null,
|
|
88
|
+
lastSignal: null,
|
|
81
89
|
},
|
|
82
90
|
});
|
|
83
91
|
}
|
|
@@ -143,6 +151,7 @@ class DaemonProcessManager {
|
|
|
143
151
|
async stopAgent(agent) {
|
|
144
152
|
const state = this.requireAgent(agent);
|
|
145
153
|
this.clearRestartTimer(state);
|
|
154
|
+
this.clearCooldownTimer(state);
|
|
146
155
|
state.stopRequested = true;
|
|
147
156
|
if (!state.process) {
|
|
148
157
|
state.snapshot.status = "stopped";
|
|
@@ -203,6 +212,8 @@ class DaemonProcessManager {
|
|
|
203
212
|
return;
|
|
204
213
|
state.process = null;
|
|
205
214
|
state.snapshot.pid = null;
|
|
215
|
+
state.snapshot.lastExitCode = code;
|
|
216
|
+
state.snapshot.lastSignal = signal;
|
|
206
217
|
const crashed = !state.stopRequested && code !== 0;
|
|
207
218
|
const now = this.now();
|
|
208
219
|
const startedAt = state.snapshot.startedAt ? Date.parse(state.snapshot.startedAt) : now;
|
|
@@ -233,6 +244,7 @@ class DaemonProcessManager {
|
|
|
233
244
|
message: "managed agent exceeded restart limit and is marked crashed",
|
|
234
245
|
meta: { agent: state.config.name, maxRestartsPerHour: this.maxRestartsPerHour },
|
|
235
246
|
});
|
|
247
|
+
this.scheduleCooldownRecovery(state);
|
|
236
248
|
return;
|
|
237
249
|
}
|
|
238
250
|
state.snapshot.status = "starting";
|
|
@@ -250,6 +262,45 @@ class DaemonProcessManager {
|
|
|
250
262
|
this.clearTimeoutFn(state.restartTimer);
|
|
251
263
|
state.restartTimer = null;
|
|
252
264
|
}
|
|
265
|
+
scheduleCooldownRecovery(state) {
|
|
266
|
+
if (state.cooldownRetryCount >= this.maxCooldownRetries) {
|
|
267
|
+
(0, runtime_1.emitNervesEvent)({
|
|
268
|
+
level: "error",
|
|
269
|
+
component: "daemon",
|
|
270
|
+
event: "daemon.agent_permanent_failure",
|
|
271
|
+
message: "managed agent exhausted all cooldown retries — permanently stopped",
|
|
272
|
+
meta: { agent: state.config.name, cooldownRetryCount: state.cooldownRetryCount, maxCooldownRetries: this.maxCooldownRetries },
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
this.clearCooldownTimer(state);
|
|
277
|
+
state.cooldownTimer = this.setTimeoutFn(() => {
|
|
278
|
+
state.cooldownRetryCount += 1;
|
|
279
|
+
state.crashTimestamps = [];
|
|
280
|
+
state.snapshot.backoffMs = this.initialBackoffMs;
|
|
281
|
+
state.snapshot.status = "starting";
|
|
282
|
+
state.snapshot.restartCount += 1;
|
|
283
|
+
(0, runtime_1.emitNervesEvent)({
|
|
284
|
+
component: "daemon",
|
|
285
|
+
event: "daemon.agent_cooldown_recovery",
|
|
286
|
+
message: "attempting cooldown recovery for managed agent",
|
|
287
|
+
meta: { agent: state.config.name, cooldownRetryCount: state.cooldownRetryCount },
|
|
288
|
+
});
|
|
289
|
+
void this.startAgent(state.config.name);
|
|
290
|
+
}, this.cooldownRecoveryMs);
|
|
291
|
+
(0, runtime_1.emitNervesEvent)({
|
|
292
|
+
component: "daemon",
|
|
293
|
+
event: "daemon.agent_cooldown_scheduled",
|
|
294
|
+
message: `scheduled cooldown recovery in ${this.cooldownRecoveryMs}ms`,
|
|
295
|
+
meta: { agent: state.config.name, cooldownRecoveryMs: this.cooldownRecoveryMs, cooldownRetryCount: state.cooldownRetryCount },
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
clearCooldownTimer(state) {
|
|
299
|
+
if (state.cooldownTimer === null)
|
|
300
|
+
return;
|
|
301
|
+
this.clearTimeoutFn(state.cooldownTimer);
|
|
302
|
+
state.cooldownTimer = null;
|
|
303
|
+
}
|
|
253
304
|
requireAgent(agent) {
|
|
254
305
|
const state = this.agents.get(agent);
|
|
255
306
|
if (!state) {
|
package/dist/nerves/index.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.formatTerminalEntry = formatTerminalEntry;
|
|
|
7
7
|
exports.registerSpinnerHooks = registerSpinnerHooks;
|
|
8
8
|
exports.createTerminalSink = createTerminalSink;
|
|
9
9
|
exports.createStderrSink = createStderrSink;
|
|
10
|
+
exports.rotateIfNeeded = rotateIfNeeded;
|
|
10
11
|
exports.createNdjsonFileSink = createNdjsonFileSink;
|
|
11
12
|
exports.registerGlobalLogSink = registerGlobalLogSink;
|
|
12
13
|
exports.createLogger = createLogger;
|
|
@@ -99,22 +100,66 @@ function createTerminalSink(write = (chunk) => process.stderr.write(chunk), colo
|
|
|
99
100
|
function createStderrSink(write = (chunk) => process.stderr.write(chunk)) {
|
|
100
101
|
return createTerminalSink(write);
|
|
101
102
|
}
|
|
102
|
-
|
|
103
|
+
const DEFAULT_MAX_LOG_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
|
104
|
+
const ROTATION_CHECK_INTERVAL_BYTES = 1024 * 1024; // ~1MB between stat checks
|
|
105
|
+
/**
|
|
106
|
+
* Rotate a log file: shift .1 -> .2, delete old .2, rename current -> .1.
|
|
107
|
+
* Uses the ndjson extension pattern: file.ndjson -> file.1.ndjson -> file.2.ndjson
|
|
108
|
+
*/
|
|
109
|
+
function rotateIfNeeded(filePath, maxSizeBytes) {
|
|
110
|
+
let size;
|
|
111
|
+
try {
|
|
112
|
+
size = (0, fs_1.statSync)(filePath).size;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
if (size < maxSizeBytes)
|
|
118
|
+
return false;
|
|
119
|
+
const ext = filePath.endsWith(".ndjson") ? ".ndjson" : "";
|
|
120
|
+
const base = ext ? filePath.slice(0, -ext.length) : filePath;
|
|
121
|
+
const file1 = `${base}.1${ext}`;
|
|
122
|
+
const file2 = `${base}.2${ext}`;
|
|
123
|
+
try {
|
|
124
|
+
(0, fs_1.unlinkSync)(file2);
|
|
125
|
+
}
|
|
126
|
+
catch { /* may not exist */ }
|
|
127
|
+
try {
|
|
128
|
+
(0, fs_1.renameSync)(file1, file2);
|
|
129
|
+
}
|
|
130
|
+
catch { /* may not exist */ }
|
|
131
|
+
try {
|
|
132
|
+
(0, fs_1.renameSync)(filePath, file1);
|
|
133
|
+
}
|
|
134
|
+
catch { /* best effort */ }
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
function createNdjsonFileSink(filePath, maxSizeBytes) {
|
|
103
138
|
(0, fs_1.mkdirSync)((0, path_1.dirname)(filePath), { recursive: true });
|
|
104
139
|
const queue = [];
|
|
105
140
|
let flushing = false;
|
|
141
|
+
let bytesSinceCheck = 0;
|
|
142
|
+
const maxSize = maxSizeBytes ?? DEFAULT_MAX_LOG_SIZE_BYTES;
|
|
106
143
|
function flush() {
|
|
107
144
|
if (flushing || queue.length === 0)
|
|
108
145
|
return;
|
|
109
146
|
flushing = true;
|
|
110
147
|
const line = queue.shift();
|
|
148
|
+
/* v8 ignore start — rotation triggers only after ~1MB of writes @preserve */
|
|
149
|
+
if (bytesSinceCheck >= ROTATION_CHECK_INTERVAL_BYTES) {
|
|
150
|
+
bytesSinceCheck = 0;
|
|
151
|
+
rotateIfNeeded(filePath, maxSize);
|
|
152
|
+
}
|
|
153
|
+
/* v8 ignore stop */
|
|
111
154
|
(0, fs_1.appendFile)(filePath, line, "utf8", () => {
|
|
112
155
|
flushing = false;
|
|
113
156
|
flush();
|
|
114
157
|
});
|
|
115
158
|
}
|
|
116
159
|
return (entry) => {
|
|
117
|
-
|
|
160
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
161
|
+
bytesSinceCheck += line.length;
|
|
162
|
+
queue.push(line);
|
|
118
163
|
flush();
|
|
119
164
|
};
|
|
120
165
|
}
|