@ouro.bot/cli 0.1.0-alpha.727 → 0.1.0-alpha.729
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 +14 -1
- package/assets/bluebubbles-host +264 -0
- package/changelog.json +16 -0
- package/dist/heart/config.js +1 -2
- package/dist/heart/daemon/bluebubbles-cli-integration.js +71 -0
- package/dist/heart/daemon/bluebubbles-host-protocol.js +433 -0
- package/dist/heart/daemon/bluebubbles-host.js +273 -0
- package/dist/heart/daemon/cli-defaults.js +8 -1
- package/dist/heart/daemon/cli-exec.js +239 -34
- package/dist/heart/daemon/cli-help.js +17 -6
- package/dist/heart/daemon/cli-parse.js +69 -1
- package/dist/heart/daemon/cli-render.js +7 -0
- package/dist/heart/daemon/doctor.js +21 -1
- package/dist/heart/daemon/mcp-canary.js +160 -29
- package/dist/heart/daemon/process-manager.js +24 -22
- package/dist/heart/daemon/sense-manager.js +1 -1
- package/dist/heart/daemon/socket-client.js +28 -19
- package/dist/heart/versioning/ouro-path-installer.js +30 -24
- package/dist/heart/versioning/ouro-recovery-launcher.js +76 -0
- package/dist/heart/versioning/version-intent.js +114 -0
- package/dist/senses/bluebubbles/index.js +16 -0
- package/dist/senses/bluebubbles/webhook-registration.js +228 -0
- package/package.json +4 -2
|
@@ -118,6 +118,7 @@ function usage() {
|
|
|
118
118
|
" ouro link <agent> --friend <id> --provider <provider> --external-id <external-id>",
|
|
119
119
|
" ouro bluebubbles replay [--agent <name>] --message-guid <guid> [--event-type new-message|updated-message] [--json]",
|
|
120
120
|
" ouro bluebubbles context-smoke [--agent <name>] --message-guid <guid> [--persist] [--json]",
|
|
121
|
+
" ouro bluebubbles host <install|status|repair|remove|collect> [--username <name> --uid <uid> --home <path>] [--request-id <id>] [--json]",
|
|
121
122
|
" ouro rsvp <doctor|incident|cutover|legacy-render|replay|config|habit|import-legacy|refresh|compare|smoke> ...",
|
|
122
123
|
" ouro friend list [--agent <name>]",
|
|
123
124
|
" ouro friend show <id> [--agent <name>]",
|
|
@@ -1543,6 +1544,7 @@ function parseMcpCommand(args) {
|
|
|
1543
1544
|
if (sub === "doctor") {
|
|
1544
1545
|
let socketOverride;
|
|
1545
1546
|
let json = false;
|
|
1547
|
+
let hostStallObserved = false;
|
|
1546
1548
|
for (let i = 0; i < rest.length; i++) {
|
|
1547
1549
|
if (rest[i] === "--socket") {
|
|
1548
1550
|
if (!rest[i + 1])
|
|
@@ -1554,6 +1556,10 @@ function parseMcpCommand(args) {
|
|
|
1554
1556
|
json = true;
|
|
1555
1557
|
continue;
|
|
1556
1558
|
}
|
|
1559
|
+
if (rest[i] === "--host-stall-observed") {
|
|
1560
|
+
hostStallObserved = true;
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1557
1563
|
throw new Error(`Unknown mcp doctor flag: ${rest[i]}`);
|
|
1558
1564
|
}
|
|
1559
1565
|
if (!agent)
|
|
@@ -1563,6 +1569,7 @@ function parseMcpCommand(args) {
|
|
|
1563
1569
|
agent,
|
|
1564
1570
|
...(socketOverride ? { socketOverride } : {}),
|
|
1565
1571
|
...(json ? { json: true } : {}),
|
|
1572
|
+
...(hostStallObserved ? { hostStallObserved: true } : {}),
|
|
1566
1573
|
};
|
|
1567
1574
|
}
|
|
1568
1575
|
throw new Error(`Usage\n${usage()}`);
|
|
@@ -1751,6 +1758,62 @@ function parseMigrateToDeskCommand(args) {
|
|
|
1751
1758
|
}
|
|
1752
1759
|
function parseBlueBubblesCommand(args) {
|
|
1753
1760
|
const subcommand = args[0];
|
|
1761
|
+
if (subcommand === "host") {
|
|
1762
|
+
const action = args[1];
|
|
1763
|
+
const usageText = "Usage: ouro bluebubbles host <install|status|repair|remove|collect> [--username <name> --uid <uid> --home <path>] [--request-id <id>] [--json]";
|
|
1764
|
+
if (action === "collect") {
|
|
1765
|
+
let requestId;
|
|
1766
|
+
let json = false;
|
|
1767
|
+
for (let index = 2; index < args.length; index += 1) {
|
|
1768
|
+
if (args[index] === "--request-id" && args[index + 1]) {
|
|
1769
|
+
requestId = args[++index];
|
|
1770
|
+
}
|
|
1771
|
+
else if (args[index] === "--json") {
|
|
1772
|
+
json = true;
|
|
1773
|
+
}
|
|
1774
|
+
else {
|
|
1775
|
+
throw new Error(usageText);
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
if (!requestId)
|
|
1779
|
+
throw new Error(`${usageText}\ncollect requires --request-id <id>`);
|
|
1780
|
+
return { kind: "bluebubbles.host.collect", requestId, ...(json ? { json: true } : {}) };
|
|
1781
|
+
}
|
|
1782
|
+
if (action !== "install" && action !== "status" && action !== "repair" && action !== "remove") {
|
|
1783
|
+
throw new Error(usageText);
|
|
1784
|
+
}
|
|
1785
|
+
let username;
|
|
1786
|
+
let uid;
|
|
1787
|
+
let homeDir;
|
|
1788
|
+
let json = false;
|
|
1789
|
+
for (let index = 2; index < args.length; index += 1) {
|
|
1790
|
+
const token = args[index];
|
|
1791
|
+
if (token === "--username" && args[index + 1])
|
|
1792
|
+
username = args[++index];
|
|
1793
|
+
else if (token === "--uid" && args[index + 1]) {
|
|
1794
|
+
const value = Number(args[++index]);
|
|
1795
|
+
if (!Number.isInteger(value))
|
|
1796
|
+
throw new Error(`${usageText}\n--uid must be an integer`);
|
|
1797
|
+
uid = value;
|
|
1798
|
+
}
|
|
1799
|
+
else if (token === "--home" && args[index + 1])
|
|
1800
|
+
homeDir = args[++index];
|
|
1801
|
+
else if (token === "--json")
|
|
1802
|
+
json = true;
|
|
1803
|
+
else
|
|
1804
|
+
throw new Error(usageText);
|
|
1805
|
+
}
|
|
1806
|
+
const targetFields = [username, uid, homeDir].filter((value) => value !== undefined).length;
|
|
1807
|
+
if (targetFields !== 0 && targetFields !== 3) {
|
|
1808
|
+
throw new Error(`${usageText}\ncross-user setup requires --username, --uid, and --home together`);
|
|
1809
|
+
}
|
|
1810
|
+
return {
|
|
1811
|
+
kind: "bluebubbles.host",
|
|
1812
|
+
action,
|
|
1813
|
+
...(targetFields === 3 ? { target: { username: username, uid: uid, homeDir: homeDir } } : {}),
|
|
1814
|
+
...(json ? { json: true } : {}),
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1754
1817
|
if (subcommand !== "replay" && subcommand !== "context-smoke") {
|
|
1755
1818
|
throw new Error(`Usage\n${usage()}`);
|
|
1756
1819
|
}
|
|
@@ -2442,7 +2505,12 @@ function parseOuroCommand(args) {
|
|
|
2442
2505
|
}
|
|
2443
2506
|
if (head === "up") {
|
|
2444
2507
|
const noRepair = args.includes("--no-repair");
|
|
2445
|
-
|
|
2508
|
+
const latest = args.includes("--latest");
|
|
2509
|
+
return {
|
|
2510
|
+
kind: "daemon.up",
|
|
2511
|
+
...(noRepair ? { noRepair: true } : {}),
|
|
2512
|
+
...(latest ? { latest: true } : {}),
|
|
2513
|
+
};
|
|
2446
2514
|
}
|
|
2447
2515
|
if (head === "dev") {
|
|
2448
2516
|
const devArgs = args.slice(1);
|
|
@@ -48,6 +48,7 @@ exports.buildStoppedStatusPayload = buildStoppedStatusPayload;
|
|
|
48
48
|
exports.renderRollupStatusLine = renderRollupStatusLine;
|
|
49
49
|
exports.daemonUnavailableStatusOutput = daemonUnavailableStatusOutput;
|
|
50
50
|
exports.isDaemonUnavailableError = isDaemonUnavailableError;
|
|
51
|
+
exports.isDaemonTimeoutError = isDaemonTimeoutError;
|
|
51
52
|
exports.formatMcpResponse = formatMcpResponse;
|
|
52
53
|
const path = __importStar(require("path"));
|
|
53
54
|
const runtime_metadata_1 = require("./runtime-metadata");
|
|
@@ -746,6 +747,12 @@ function isDaemonUnavailableError(error) {
|
|
|
746
747
|
: "";
|
|
747
748
|
return code === "ENOENT" || code === "ECONNREFUSED";
|
|
748
749
|
}
|
|
750
|
+
function isDaemonTimeoutError(error) {
|
|
751
|
+
const code = typeof error === "object" && error !== null && "code" in error
|
|
752
|
+
? String(error.code ?? "")
|
|
753
|
+
: "";
|
|
754
|
+
return code === "ETIMEDOUT";
|
|
755
|
+
}
|
|
749
756
|
function formatMcpResponse(command, response) {
|
|
750
757
|
if (command.kind === "mcp.list") {
|
|
751
758
|
const allTools = response.data;
|
|
@@ -24,6 +24,7 @@ exports.checkLifecycle = checkLifecycle;
|
|
|
24
24
|
exports.runDoctorChecks = runDoctorChecks;
|
|
25
25
|
const runtime_1 = require("../../nerves/runtime");
|
|
26
26
|
const bluebubbles_health_diagnostics_1 = require("./bluebubbles-health-diagnostics");
|
|
27
|
+
const webhook_registration_1 = require("../../senses/bluebubbles/webhook-registration");
|
|
27
28
|
const freshness_1 = require("./freshness");
|
|
28
29
|
const ouro_path_installer_1 = require("../versioning/ouro-path-installer");
|
|
29
30
|
const runtime_credentials_1 = require("../runtime-credentials");
|
|
@@ -35,6 +36,8 @@ const config_1 = require("../../rsvp/config");
|
|
|
35
36
|
const cutover_1 = require("../../rsvp/cutover");
|
|
36
37
|
const diagnostics_1 = require("../../rsvp/diagnostics");
|
|
37
38
|
const DEFAULT_BLUEBUBBLES_REQUEST_TIMEOUT_MS = 30_000;
|
|
39
|
+
const DEFAULT_BLUEBUBBLES_PORT = 18_790;
|
|
40
|
+
const DEFAULT_BLUEBUBBLES_WEBHOOK_PATH = "/bluebubbles-webhook";
|
|
38
41
|
// ── Category checkers ──
|
|
39
42
|
function checkCliPath(deps) {
|
|
40
43
|
const resolution = (0, ouro_path_installer_1.diagnoseOuroPath)({
|
|
@@ -412,10 +415,11 @@ async function checkSenses(deps) {
|
|
|
412
415
|
});
|
|
413
416
|
let upstreamContext = "upstream probe not run in this pass";
|
|
414
417
|
if (deps.fetchImpl) {
|
|
418
|
+
const requestTimeoutMs = numberField(bluebubblesChannel, "requestTimeoutMs", DEFAULT_BLUEBUBBLES_REQUEST_TIMEOUT_MS);
|
|
415
419
|
const probe = await (0, bluebubbles_health_diagnostics_1.probeBlueBubblesHealth)({
|
|
416
420
|
serverUrl,
|
|
417
421
|
password,
|
|
418
|
-
requestTimeoutMs
|
|
422
|
+
requestTimeoutMs,
|
|
419
423
|
fetchImpl: deps.fetchImpl,
|
|
420
424
|
});
|
|
421
425
|
checks.push({
|
|
@@ -426,6 +430,22 @@ async function checkSenses(deps) {
|
|
|
426
430
|
upstreamContext = probe.ok
|
|
427
431
|
? "upstream probe reachable — which does not prove inbound delivery"
|
|
428
432
|
: "upstream probe failing";
|
|
433
|
+
const webhook = await (0, webhook_registration_1.inspectBlueBubblesWebhookRegistration)({
|
|
434
|
+
serverUrl,
|
|
435
|
+
password,
|
|
436
|
+
callbackPort: numberField(bluebubblesChannel, "port", DEFAULT_BLUEBUBBLES_PORT),
|
|
437
|
+
callbackPath: textField(bluebubblesChannel, "webhookPath") || DEFAULT_BLUEBUBBLES_WEBHOOK_PATH,
|
|
438
|
+
agentName,
|
|
439
|
+
machineId,
|
|
440
|
+
requestTimeoutMs,
|
|
441
|
+
listenerReady: true,
|
|
442
|
+
}, { fetchImpl: deps.fetchImpl });
|
|
443
|
+
checks.push({
|
|
444
|
+
id: "senses.bluebubbles.webhook",
|
|
445
|
+
label: `${agentDir} bluebubbles webhook`,
|
|
446
|
+
status: webhook.ok ? "pass" : "fail",
|
|
447
|
+
detail: webhook.detail,
|
|
448
|
+
});
|
|
429
449
|
}
|
|
430
450
|
const bluebubblesStateRoot = `${deps.bundlesRoot}/${agentDir}/state/senses/bluebubbles`;
|
|
431
451
|
checks.push(senseInboundDeliveryCheck({
|
|
@@ -33,7 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.DEFAULT_CANARY_TIMEOUT_MS = void 0;
|
|
36
|
+
exports.SETUP_CANARY_TIMEOUT_MS = exports.DEFAULT_CANARY_TIMEOUT_MS = void 0;
|
|
37
|
+
exports.classifyMcpBoundary = classifyMcpBoundary;
|
|
38
|
+
exports.sanitizeMcpCanaryText = sanitizeMcpCanaryText;
|
|
39
|
+
exports.sanitizeMcpCanaryArgs = sanitizeMcpCanaryArgs;
|
|
37
40
|
exports.buildMcpBridgeRepairGuidance = buildMcpBridgeRepairGuidance;
|
|
38
41
|
exports.formatMcpBridgeRepairDetails = formatMcpBridgeRepairDetails;
|
|
39
42
|
exports.buildMcpDoctorNextSteps = buildMcpDoctorNextSteps;
|
|
@@ -47,7 +50,40 @@ const child_process_1 = require("child_process");
|
|
|
47
50
|
const path = __importStar(require("path"));
|
|
48
51
|
const runtime_1 = require("../../nerves/runtime");
|
|
49
52
|
exports.DEFAULT_CANARY_TIMEOUT_MS = 60_000;
|
|
53
|
+
exports.SETUP_CANARY_TIMEOUT_MS = 10_000;
|
|
50
54
|
const MCP_PROTOCOL_VERSION = "2024-11-05";
|
|
55
|
+
function classifyMcpBoundary(input) {
|
|
56
|
+
if (!input.bridgeHealthy)
|
|
57
|
+
return "ouro-bridge-failed";
|
|
58
|
+
return input.hostStallObserved ? "host-stall-unexplained" : "ouro-bridge-healthy-at-capture";
|
|
59
|
+
}
|
|
60
|
+
function sanitizeMcpCanaryText(text) {
|
|
61
|
+
return text
|
|
62
|
+
.replace(/(https?:\/\/[^\s?]+)\?[^\s]*/g, "$1?[redacted]")
|
|
63
|
+
.replace(/"(?:password|token|secret|api[_-]?key)"\s*:\s*"[^"]*"/gi, '"credential":"[redacted]"')
|
|
64
|
+
.replace(/\bBearer\s+[^\s]+/gi, "Bearer [redacted]")
|
|
65
|
+
.replace(/--(?:password|token|secret|api[_-]?key)(\s+|=)[^\s]+/gi, "--credential$1[redacted]")
|
|
66
|
+
.replace(/\b(?:password|token|secret|api[_-]?key)=([^\s]+)/gi, "credential=[redacted]")
|
|
67
|
+
.trim();
|
|
68
|
+
}
|
|
69
|
+
function sanitizeMcpCanaryArgs(args) {
|
|
70
|
+
const sanitized = [];
|
|
71
|
+
let redactNext = false;
|
|
72
|
+
for (const arg of args) {
|
|
73
|
+
if (redactNext) {
|
|
74
|
+
sanitized.push("[redacted]");
|
|
75
|
+
redactNext = false;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (/^--(?:password|token|secret|api[_-]?key)$/i.test(arg)) {
|
|
79
|
+
sanitized.push("--credential");
|
|
80
|
+
redactNext = true;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
sanitized.push(sanitizeMcpCanaryText(arg));
|
|
84
|
+
}
|
|
85
|
+
return sanitized;
|
|
86
|
+
}
|
|
51
87
|
function defaultCommandArgs(agent, socketPath) {
|
|
52
88
|
const entryPath = path.join(__dirname, "ouro-bot-entry.js");
|
|
53
89
|
return [
|
|
@@ -58,18 +94,32 @@ function defaultCommandArgs(agent, socketPath) {
|
|
|
58
94
|
...(socketPath ? ["--socket", socketPath] : []),
|
|
59
95
|
];
|
|
60
96
|
}
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
97
|
+
function isRecord(value) {
|
|
98
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
99
|
+
}
|
|
100
|
+
function requireInitializeResult(response) {
|
|
101
|
+
if (!isRecord(response.result)) {
|
|
102
|
+
throw new Error("MCP canary received invalid initialize result");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function requireStatusText(response) {
|
|
106
|
+
if (!isRecord(response.result)) {
|
|
107
|
+
throw new Error("MCP canary received invalid status result");
|
|
108
|
+
}
|
|
109
|
+
if (response.result.isError === true) {
|
|
110
|
+
const errorContent = response.result.content;
|
|
111
|
+
const errorFirst = Array.isArray(errorContent) ? errorContent[0] : undefined;
|
|
112
|
+
const errorText = isRecord(errorFirst) && typeof errorFirst.text === "string"
|
|
113
|
+
? errorFirst.text
|
|
114
|
+
: "MCP status tool returned an error";
|
|
115
|
+
throw new Error(errorText);
|
|
116
|
+
}
|
|
117
|
+
const content = response.result.content;
|
|
118
|
+
const first = Array.isArray(content) ? content[0] : undefined;
|
|
119
|
+
if (!isRecord(first) || typeof first.text !== "string") {
|
|
120
|
+
throw new Error("MCP canary received invalid status result");
|
|
121
|
+
}
|
|
122
|
+
return first.text;
|
|
73
123
|
}
|
|
74
124
|
function parseFields(line) {
|
|
75
125
|
const parsed = {};
|
|
@@ -214,7 +264,23 @@ function validateMcpStatus(parsed, requiredSenses, options = {}) {
|
|
|
214
264
|
};
|
|
215
265
|
}
|
|
216
266
|
async function runMcpStatusCanary(options) {
|
|
267
|
+
const startedAt = Date.now();
|
|
217
268
|
const timeoutMs = options.timeoutMs ?? exports.DEFAULT_CANARY_TIMEOUT_MS;
|
|
269
|
+
const deadlineAt = startedAt + timeoutMs;
|
|
270
|
+
const evidence = {
|
|
271
|
+
capturedAt: new Date(startedAt).toISOString(),
|
|
272
|
+
durationMs: 0,
|
|
273
|
+
childPid: null,
|
|
274
|
+
phase: "spawn",
|
|
275
|
+
exitCode: null,
|
|
276
|
+
exitSignal: null,
|
|
277
|
+
stderr: "",
|
|
278
|
+
};
|
|
279
|
+
const decorate = (result, bridgeHealthy = false) => ({
|
|
280
|
+
...result,
|
|
281
|
+
classification: classifyMcpBoundary({ bridgeHealthy, hostStallObserved: options.hostStallObserved === true }),
|
|
282
|
+
evidence,
|
|
283
|
+
});
|
|
218
284
|
/* v8 ignore next -- default spawn is exercised by live canaries, while unit tests inject a fake child @preserve */
|
|
219
285
|
const spawnImpl = options.spawnImpl ?? child_process_1.spawn;
|
|
220
286
|
const command = options.command ?? process.execPath;
|
|
@@ -227,17 +293,38 @@ async function runMcpStatusCanary(options) {
|
|
|
227
293
|
meta: {
|
|
228
294
|
agent: options.agent,
|
|
229
295
|
command,
|
|
230
|
-
commandArgs,
|
|
296
|
+
commandArgs: sanitizeMcpCanaryArgs(commandArgs),
|
|
231
297
|
timeoutMs,
|
|
232
298
|
requiredSenses,
|
|
233
299
|
ignoreOverviewHealth: options.ignoreOverviewHealth === true,
|
|
234
300
|
ignoreSenseHealth: options.ignoreSenseHealth === true,
|
|
235
301
|
},
|
|
236
302
|
});
|
|
237
|
-
|
|
303
|
+
let child;
|
|
304
|
+
try {
|
|
305
|
+
child = spawnImpl(command, commandArgs, { stdio: ["pipe", "pipe", "pipe"] });
|
|
306
|
+
evidence.childPid = child.pid ?? null;
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
const reason = sanitizeMcpCanaryText(error instanceof Error ? error.message : String(error));
|
|
310
|
+
evidence.durationMs = Date.now() - startedAt;
|
|
311
|
+
(0, runtime_1.emitNervesEvent)({
|
|
312
|
+
component: "daemon",
|
|
313
|
+
event: "daemon.mcp_canary_error",
|
|
314
|
+
level: "error",
|
|
315
|
+
message: "MCP status canary failed to spawn",
|
|
316
|
+
meta: { agent: options.agent, reason },
|
|
317
|
+
});
|
|
318
|
+
return decorate({ ok: false, summary: `mcp canary failed: ${reason}`, details: [reason] });
|
|
319
|
+
}
|
|
238
320
|
let buffer = "";
|
|
239
321
|
let stderr = "";
|
|
240
322
|
const pending = new Map();
|
|
323
|
+
let resolveExit;
|
|
324
|
+
const exitObserved = new Promise((resolve) => { resolveExit = resolve; });
|
|
325
|
+
function safeStderr() {
|
|
326
|
+
return sanitizeMcpCanaryText(stderr);
|
|
327
|
+
}
|
|
241
328
|
function cleanup() {
|
|
242
329
|
for (const [, request] of pending) {
|
|
243
330
|
clearTimeout(request.timer);
|
|
@@ -268,14 +355,19 @@ async function runMcpStatusCanary(options) {
|
|
|
268
355
|
buffer = buffer.slice(idx + 1);
|
|
269
356
|
if (!line)
|
|
270
357
|
continue;
|
|
271
|
-
let
|
|
358
|
+
let parsedResponse;
|
|
272
359
|
try {
|
|
273
|
-
|
|
360
|
+
parsedResponse = JSON.parse(line);
|
|
274
361
|
}
|
|
275
362
|
catch {
|
|
276
363
|
failAll(new Error(`MCP canary received malformed JSON: ${line}`));
|
|
277
364
|
return;
|
|
278
365
|
}
|
|
366
|
+
if (!isRecord(parsedResponse)) {
|
|
367
|
+
failAll(new Error("MCP canary received malformed JSON-RPC response"));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const response = parsedResponse;
|
|
279
371
|
const id = typeof response.id === "number" ? response.id : null;
|
|
280
372
|
if (id === null)
|
|
281
373
|
continue;
|
|
@@ -284,14 +376,27 @@ async function runMcpStatusCanary(options) {
|
|
|
284
376
|
continue;
|
|
285
377
|
pending.delete(id);
|
|
286
378
|
clearTimeout(request.timer);
|
|
379
|
+
const hasResult = Object.prototype.hasOwnProperty.call(response, "result");
|
|
380
|
+
const hasError = Object.prototype.hasOwnProperty.call(response, "error");
|
|
381
|
+
if (response.jsonrpc !== "2.0" || (!hasResult && !hasError)) {
|
|
382
|
+
request.reject(new Error("MCP canary received malformed JSON-RPC response"));
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (hasError) {
|
|
386
|
+
request.reject(new Error(`MCP canary received JSON-RPC error: ${JSON.stringify(response.error)}`));
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
287
389
|
request.resolve(response);
|
|
288
390
|
}
|
|
289
391
|
});
|
|
290
392
|
child.on("error", (error) => failAll(error));
|
|
291
393
|
child.on("close", (code, signal) => {
|
|
394
|
+
evidence.exitCode = code;
|
|
395
|
+
evidence.exitSignal = signal;
|
|
396
|
+
resolveExit();
|
|
292
397
|
if (pending.size === 0)
|
|
293
398
|
return;
|
|
294
|
-
failAll(new Error(`MCP canary process closed before response code=${code} signal=${signal ?? "none"} stderr=${
|
|
399
|
+
failAll(new Error(`MCP canary process closed before response code=${code} signal=${signal ?? "none"} stderr=${safeStderr()}`));
|
|
295
400
|
});
|
|
296
401
|
let nextId = 1;
|
|
297
402
|
function request(method, params) {
|
|
@@ -300,36 +405,43 @@ async function runMcpStatusCanary(options) {
|
|
|
300
405
|
reject(new Error("MCP canary stdin is not writable"));
|
|
301
406
|
return;
|
|
302
407
|
}
|
|
408
|
+
const remainingMs = deadlineAt - Date.now();
|
|
409
|
+
if (remainingMs <= 0) {
|
|
410
|
+
reject(new Error(`MCP canary timed out waiting for ${method}; stderr=${safeStderr()}`));
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
303
413
|
const id = nextId++;
|
|
304
414
|
const timer = setTimeout(() => {
|
|
305
415
|
pending.delete(id);
|
|
306
|
-
reject(new Error(`MCP canary timed out waiting for ${method}; stderr=${
|
|
307
|
-
},
|
|
416
|
+
reject(new Error(`MCP canary timed out waiting for ${method}; stderr=${safeStderr()}`));
|
|
417
|
+
}, remainingMs);
|
|
308
418
|
pending.set(id, { resolve, reject, timer });
|
|
309
419
|
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
|
310
420
|
});
|
|
311
421
|
}
|
|
312
422
|
try {
|
|
313
|
-
|
|
423
|
+
evidence.phase = "initialize";
|
|
424
|
+
const initializeResponse = await request("initialize", {
|
|
314
425
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
315
426
|
capabilities: {},
|
|
316
427
|
clientInfo: { name: "ouro-mcp-canary", version: "1.0" },
|
|
317
428
|
});
|
|
429
|
+
requireInitializeResult(initializeResponse);
|
|
318
430
|
child.stdin?.write(JSON.stringify({ jsonrpc: "2.0", method: "initialized" }) + "\n");
|
|
431
|
+
evidence.phase = "status";
|
|
319
432
|
const statusResponse = await request("tools/call", {
|
|
320
433
|
name: "status",
|
|
321
434
|
arguments: {},
|
|
322
435
|
});
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
}
|
|
327
|
-
const parsed = parseMcpStatusText(responseText(statusResponse));
|
|
436
|
+
const statusText = requireStatusText(statusResponse);
|
|
437
|
+
const bridgeHealthy = true;
|
|
438
|
+
const parsed = parseMcpStatusText(sanitizeMcpCanaryText(statusText));
|
|
328
439
|
const canary = validateMcpStatus(parsed, requiredSenses, {
|
|
329
440
|
agent: options.agent,
|
|
330
441
|
ignoreOverviewHealth: options.ignoreOverviewHealth,
|
|
331
442
|
ignoreSenseHealth: options.ignoreSenseHealth,
|
|
332
443
|
});
|
|
444
|
+
evidence.phase = "complete";
|
|
333
445
|
(0, runtime_1.emitNervesEvent)({
|
|
334
446
|
component: "daemon",
|
|
335
447
|
event: canary.ok ? "daemon.mcp_canary_end" : "daemon.mcp_canary_error",
|
|
@@ -337,10 +449,10 @@ async function runMcpStatusCanary(options) {
|
|
|
337
449
|
message: canary.summary,
|
|
338
450
|
meta: { agent: options.agent, ok: canary.ok },
|
|
339
451
|
});
|
|
340
|
-
return canary;
|
|
452
|
+
return decorate(canary, bridgeHealthy);
|
|
341
453
|
}
|
|
342
454
|
catch (error) {
|
|
343
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
455
|
+
const reason = sanitizeMcpCanaryText(error instanceof Error ? error.message : String(error));
|
|
344
456
|
(0, runtime_1.emitNervesEvent)({
|
|
345
457
|
component: "daemon",
|
|
346
458
|
event: "daemon.mcp_canary_error",
|
|
@@ -348,16 +460,30 @@ async function runMcpStatusCanary(options) {
|
|
|
348
460
|
message: "MCP status canary failed",
|
|
349
461
|
meta: { agent: options.agent, reason },
|
|
350
462
|
});
|
|
351
|
-
return { ok: false, summary: `mcp canary failed: ${reason}`, details: [reason] };
|
|
463
|
+
return decorate({ ok: false, summary: `mcp canary failed: ${reason}`, details: [reason] });
|
|
352
464
|
}
|
|
353
465
|
finally {
|
|
354
466
|
child.stdin?.end();
|
|
355
467
|
cleanup();
|
|
468
|
+
let exitTimer;
|
|
469
|
+
const remainingExitWaitMs = Math.min(100, Math.max(0, deadlineAt - Date.now()));
|
|
470
|
+
await Promise.race([
|
|
471
|
+
exitObserved,
|
|
472
|
+
new Promise((resolve) => { exitTimer = setTimeout(resolve, remainingExitWaitMs); }),
|
|
473
|
+
]);
|
|
474
|
+
clearTimeout(exitTimer);
|
|
475
|
+
evidence.stderr = safeStderr();
|
|
476
|
+
evidence.durationMs = Date.now() - startedAt;
|
|
356
477
|
}
|
|
357
478
|
}
|
|
358
479
|
function formatMcpStatusCanaryResult(result) {
|
|
359
480
|
return [
|
|
360
481
|
result.ok ? "mcp canary: ok" : "mcp canary: failed",
|
|
482
|
+
...(result.classification ? [`classification: ${result.classification}`] : []),
|
|
483
|
+
...(result.evidence ? [
|
|
484
|
+
`captured=${result.evidence.capturedAt} durationMs=${result.evidence.durationMs} childPid=${result.evidence.childPid ?? "none"} phase=${result.evidence.phase} exitCode=${result.evidence.exitCode ?? "none"} exitSignal=${result.evidence.exitSignal ?? "none"}`,
|
|
485
|
+
...(result.evidence.stderr ? [`stderr=${result.evidence.stderr}`] : []),
|
|
486
|
+
] : []),
|
|
361
487
|
result.summary,
|
|
362
488
|
...result.details.map((line) => ` ${line}`),
|
|
363
489
|
].join("\n");
|
|
@@ -368,6 +494,11 @@ function formatMcpStatusDoctorResult(result, agent) {
|
|
|
368
494
|
&& !line.startsWith("verify command="));
|
|
369
495
|
const lines = [
|
|
370
496
|
result.ok ? "mcp doctor: ok" : "mcp doctor: failed",
|
|
497
|
+
...(result.classification ? [`classification: ${result.classification}`] : []),
|
|
498
|
+
...(result.evidence ? [
|
|
499
|
+
`captured=${result.evidence.capturedAt} durationMs=${result.evidence.durationMs} childPid=${result.evidence.childPid ?? "none"} phase=${result.evidence.phase} exitCode=${result.evidence.exitCode ?? "none"} exitSignal=${result.evidence.exitSignal ?? "none"}`,
|
|
500
|
+
...(result.evidence.stderr ? [`stderr=${result.evidence.stderr}`] : []),
|
|
501
|
+
] : []),
|
|
371
502
|
result.summary,
|
|
372
503
|
...rawDetails.map((line) => ` ${line}`),
|
|
373
504
|
];
|
|
@@ -672,26 +672,29 @@ class DaemonProcessManager {
|
|
|
672
672
|
state.snapshot.errorReason = null;
|
|
673
673
|
state.snapshot.fixHint = null;
|
|
674
674
|
}
|
|
675
|
-
const
|
|
675
|
+
const unexpectedExit = !state.stopRequested;
|
|
676
|
+
const crashed = unexpectedExit && code !== 0;
|
|
676
677
|
const now = this.currentTimeMs();
|
|
677
678
|
const startedAt = state.snapshot.startedAt ? Date.parse(state.snapshot.startedAt) : now;
|
|
678
679
|
const runDuration = Math.max(0, now - startedAt);
|
|
679
680
|
(0, runtime_1.emitNervesEvent)({
|
|
680
|
-
level:
|
|
681
|
+
level: unexpectedExit ? "warn" : "info",
|
|
681
682
|
component: "daemon",
|
|
682
683
|
event: "daemon.agent_exit",
|
|
683
684
|
message: "managed agent process exited",
|
|
684
|
-
meta: { agent: state.config.name, code, signal, crashed, runDurationMs: runDuration },
|
|
685
|
+
meta: { agent: state.config.name, code, signal, crashed, unexpectedExit, runDurationMs: runDuration },
|
|
685
686
|
});
|
|
686
|
-
if (
|
|
687
|
+
if (runDuration >= this.stabilityThresholdMs) {
|
|
688
|
+
state.snapshot.backoffMs = this.initialBackoffMs;
|
|
689
|
+
}
|
|
690
|
+
if (!unexpectedExit) {
|
|
687
691
|
state.snapshot.status = "stopped";
|
|
688
|
-
if (runDuration >= this.stabilityThresholdMs) {
|
|
689
|
-
state.snapshot.backoffMs = this.initialBackoffMs;
|
|
690
|
-
}
|
|
691
692
|
this.notifySnapshotChange(state.snapshot);
|
|
692
693
|
return;
|
|
693
694
|
}
|
|
694
|
-
|
|
695
|
+
if (crashed) {
|
|
696
|
+
state.snapshot.lastCrashAt = new Date(now).toISOString();
|
|
697
|
+
}
|
|
695
698
|
if (state.config.autoStart === false) {
|
|
696
699
|
state.snapshot.status = "stopped";
|
|
697
700
|
state.snapshot.errorReason = "non-autostart worker exited unexpectedly; not respawning";
|
|
@@ -699,46 +702,45 @@ class DaemonProcessManager {
|
|
|
699
702
|
this.notifySnapshotChange(state.snapshot);
|
|
700
703
|
return;
|
|
701
704
|
}
|
|
702
|
-
// Fast-
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
+
// Fast-exit detection: repeated early exits, including clean exit-code-0
|
|
706
|
+
// terminations, indicate a startup/configuration or lifecycle fault rather
|
|
707
|
+
// than a stable long-lived worker. Bound them without claiming a crash.
|
|
705
708
|
const FAST_CRASH_THRESHOLD_MS = 5000;
|
|
706
709
|
const FAST_CRASH_MAX = 3;
|
|
707
710
|
if (runDuration < FAST_CRASH_THRESHOLD_MS) {
|
|
708
711
|
state.fastCrashCount = state.fastCrashCount + 1;
|
|
709
712
|
if (state.fastCrashCount >= FAST_CRASH_MAX) {
|
|
710
713
|
state.snapshot.status = "crashed";
|
|
711
|
-
// Capture the fast-
|
|
712
|
-
//
|
|
713
|
-
|
|
714
|
-
state.snapshot.
|
|
715
|
-
state.snapshot.fixHint = `Fix the config and run \`ouro up\` to restart, or check daemon logs for the underlying error.`;
|
|
714
|
+
// Capture the fast-exit diagnosis on the snapshot so it surfaces via
|
|
715
|
+
// the pulse with a prescriptive, cause-neutral repair path.
|
|
716
|
+
state.snapshot.errorReason = `agent exited unexpectedly ${FAST_CRASH_MAX} times within ${FAST_CRASH_THRESHOLD_MS}ms of startup — likely a startup/configuration or lifecycle issue.`;
|
|
717
|
+
state.snapshot.fixHint = `Inspect daemon logs and lifecycle/configuration, then run \`ouro up\` to restart.`;
|
|
716
718
|
(0, runtime_1.emitNervesEvent)({
|
|
717
719
|
level: "error",
|
|
718
720
|
component: "daemon",
|
|
719
721
|
event: "daemon.agent_config_failure",
|
|
720
|
-
message: `agent
|
|
721
|
-
meta: { agent: state.config.name,
|
|
722
|
+
message: `agent exited unexpectedly ${FAST_CRASH_MAX} times within ${FAST_CRASH_THRESHOLD_MS}ms of startup. Not retrying. Inspect lifecycle/configuration and run \`ouro up\` to restart.`,
|
|
723
|
+
meta: { agent: state.config.name, fastUnexpectedExitCount: state.fastCrashCount, avgRunDurationMs: runDuration },
|
|
722
724
|
});
|
|
723
725
|
this.notifySnapshotChange(state.snapshot);
|
|
724
726
|
return; // Don't schedule cooldown recovery — this needs human/agent intervention
|
|
725
727
|
}
|
|
726
728
|
}
|
|
727
729
|
else {
|
|
728
|
-
// Reset fast-
|
|
730
|
+
// Reset the fast unexpected-exit counter on a stable run.
|
|
729
731
|
state.fastCrashCount = 0;
|
|
730
732
|
}
|
|
731
733
|
state.crashTimestamps = state.crashTimestamps.filter((crashTs) => crashTs >= startOfHour(now));
|
|
732
734
|
state.crashTimestamps.push(now);
|
|
733
735
|
if (state.crashTimestamps.length > this.maxRestartsPerHour) {
|
|
734
736
|
state.snapshot.status = "crashed";
|
|
735
|
-
state.snapshot.errorReason = `agent exceeded restart limit (${this.maxRestartsPerHour}/hr) — entering cooldown`;
|
|
736
|
-
state.snapshot.fixHint = "investigate why the agent keeps
|
|
737
|
+
state.snapshot.errorReason = `agent exceeded unexpected-exit restart limit (${this.maxRestartsPerHour}/hr) — entering cooldown`;
|
|
738
|
+
state.snapshot.fixHint = "investigate why the agent keeps exiting unexpectedly; cooldown will retry shortly";
|
|
737
739
|
(0, runtime_1.emitNervesEvent)({
|
|
738
740
|
level: "error",
|
|
739
741
|
component: "daemon",
|
|
740
742
|
event: "daemon.agent_restart_exhausted",
|
|
741
|
-
message: "managed agent exceeded restart limit and is marked crashed",
|
|
743
|
+
message: "managed agent exceeded unexpected-exit restart limit and is marked crashed",
|
|
742
744
|
meta: { agent: state.config.name, maxRestartsPerHour: this.maxRestartsPerHour },
|
|
743
745
|
});
|
|
744
746
|
this.notifySnapshotChange(state.snapshot);
|
|
@@ -488,7 +488,7 @@ function readBlueBubblesRuntimeFacts(agent, bundlesRoot, snapshot) {
|
|
|
488
488
|
...proofFacts,
|
|
489
489
|
lastFailure: "listener process is not running",
|
|
490
490
|
failureLayer: "listener",
|
|
491
|
-
recoveryAction: "daemon
|
|
491
|
+
recoveryAction: "daemon process manager will restart the BlueBubbles listener after an unsolicited exit",
|
|
492
492
|
};
|
|
493
493
|
}
|
|
494
494
|
if (state.upstreamStatus === "error") {
|