diff --git a/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md b/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md index 1cceb26e995..ef224b30d32 100644 --- a/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md +++ b/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md @@ -414,6 +414,25 @@ In that case: - inspect gateway logs and blocked requests with `openshell term` - treat the failure as a native Discord gateway problem, not as a bridge startup problem +### Messaging bridge appears running but no messages arrive + +Bot tokens for Telegram (`getUpdates`), Discord (gateway), and Slack (Socket Mode) only allow one active consumer per token. If two NemoClaw sandboxes are configured with the same bot token, each one kicks the other off its polling connection and neither delivers messages. `nemoclaw status` still reports the bridge as running because the gateway process itself is alive. + +To diagnose, open a shell in the sandbox and inspect the gateway log: + +```console +$ openshell term +$ tail -f /tmp/gateway.log +``` + +A repeating line like the following confirms the conflict: + +```text +[telegram] getUpdates conflict: 409: Conflict: terminated by other getUpdates request; retrying in 30s. +``` + +To fix, run `nemoclaw destroy` on whichever sandbox should stop polling, or rerun onboarding on it with the channel disabled. Current NemoClaw warns at `nemoclaw onboard` time when another sandbox already has the same channel enabled, but sandboxes created before that check was added may still be in a conflict loop. + ### Landlock filesystem restrictions silently degraded After sandbox creation, NemoClaw checks whether the host kernel supports Landlock (Linux 5.13+). diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index 7c97178c739..c1bf19f2794 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -444,6 +444,25 @@ In that case: - inspect gateway logs and blocked requests with `openshell term` - treat the failure as a native Discord gateway problem, not as a bridge startup problem +### Messaging bridge appears running but no messages arrive + +Bot tokens for Telegram (`getUpdates`), Discord (gateway), and Slack (Socket Mode) only allow one active consumer per token. If two NemoClaw sandboxes are configured with the same bot token, each one kicks the other off its polling connection and neither delivers messages. `nemoclaw status` still reports the bridge as running because the gateway process itself is alive. + +To diagnose, open a shell in the sandbox and inspect the gateway log: + +```console +$ openshell term +$ tail -f /tmp/gateway.log +``` + +A repeating line like the following confirms the conflict: + +```text +[telegram] getUpdates conflict: 409: Conflict: terminated by other getUpdates request; retrying in 30s. +``` + +To fix, run `nemoclaw destroy` on whichever sandbox should stop polling, or rerun onboarding on it with the channel disabled. Current NemoClaw warns at `nemoclaw onboard` time when another sandbox already has the same channel enabled, but sandboxes created before that check was added may still be in a conflict loop. + ### Landlock filesystem restrictions silently degraded After sandbox creation, NemoClaw checks whether the host kernel supports Landlock (Linux 5.13+). diff --git a/src/lib/inventory-commands.test.ts b/src/lib/inventory-commands.test.ts index 122564443d0..c4c5a4e2974 100644 --- a/src/lib/inventory-commands.test.ts +++ b/src/lib/inventory-commands.test.ts @@ -88,6 +88,77 @@ describe("inventory commands", () => { ); }); + it("flags messaging bridge as degraded when checkMessagingBridgeHealth reports conflicts", () => { + const lines: string[] = []; + const checkMessagingBridgeHealth = vi.fn().mockReturnValue([ + { channel: "telegram", conflicts: 7 }, + ]); + showStatusCommand({ + listSandboxes: () => ({ + sandboxes: [ + { + name: "alpha", + model: "m", + messagingChannels: ["telegram"], + }, + ], + defaultSandbox: "alpha", + }), + getLiveInference: () => null, + showServiceStatus: vi.fn(), + checkMessagingBridgeHealth, + log: (message = "") => lines.push(message), + }); + + expect(checkMessagingBridgeHealth).toHaveBeenCalledWith("alpha", ["telegram"]); + expect(lines).toContain( + " ⚠ telegram bridge: degraded (7 conflict errors in /tmp/gateway.log)", + ); + }); + + it("skips messaging bridge check when the default sandbox has no channels", () => { + const lines: string[] = []; + const checkMessagingBridgeHealth = vi.fn().mockReturnValue([]); + showStatusCommand({ + listSandboxes: () => ({ + sandboxes: [{ name: "alpha", model: "m" }], + defaultSandbox: "alpha", + }), + getLiveInference: () => null, + showServiceStatus: vi.fn(), + checkMessagingBridgeHealth, + log: (message = "") => lines.push(message), + }); + + expect(checkMessagingBridgeHealth).not.toHaveBeenCalled(); + expect(lines.some((l) => l.includes("degraded"))).toBe(false); + }); + + it("prints a cross-sandbox overlap warning when backfillAndFindOverlaps reports overlaps", () => { + const lines: string[] = []; + const backfillAndFindOverlaps = vi.fn().mockReturnValue([ + { channel: "telegram", sandboxes: ["alice", "bob"] }, + ]); + showStatusCommand({ + listSandboxes: () => ({ + sandboxes: [ + { name: "alice", model: "m", messagingChannels: ["telegram"] }, + { name: "bob", model: "m", messagingChannels: ["telegram"] }, + ], + defaultSandbox: "alice", + }), + getLiveInference: () => null, + showServiceStatus: vi.fn(), + backfillAndFindOverlaps, + log: (message = "") => lines.push(message), + }); + + expect(backfillAndFindOverlaps).toHaveBeenCalled(); + expect( + lines.some((l) => l.includes("telegram is enabled on both 'alice' and 'bob'")), + ).toBe(true); + }); + it("prints stored sandbox models in status and delegates service status", () => { const lines: string[] = []; const showServiceStatus = vi.fn(); diff --git a/src/lib/inventory-commands.ts b/src/lib/inventory-commands.ts index bd9ea1b9096..d7d547426e2 100644 --- a/src/lib/inventory-commands.ts +++ b/src/lib/inventory-commands.ts @@ -9,6 +9,12 @@ export interface SandboxEntry { provider?: string | null; gpuEnabled?: boolean; policies?: string[] | null; + messagingChannels?: string[] | null; +} + +export interface MessagingBridgeHealth { + channel: string; + conflicts: number; } export interface RecoveryResult { @@ -25,10 +31,20 @@ export interface ListSandboxesCommandDeps { log?: (message?: string) => void; } +export interface MessagingOverlap { + channel: string; + sandboxes: [string, string]; +} + export interface ShowStatusCommandDeps { listSandboxes: () => { sandboxes: SandboxEntry[]; defaultSandbox?: string | null }; getLiveInference: () => GatewayInference | null; showServiceStatus: (options: { sandboxName?: string }) => void; + checkMessagingBridgeHealth?: ( + sandboxName: string, + channels: string[], + ) => MessagingBridgeHealth[]; + backfillAndFindOverlaps?: () => MessagingOverlap[]; log?: (message?: string) => void; } @@ -99,4 +115,42 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void { } deps.showServiceStatus({ sandboxName: defaultSandbox || undefined }); + + if (deps.backfillAndFindOverlaps) { + const overlaps = deps.backfillAndFindOverlaps(); + if (overlaps.length > 0) { + log(""); + for (const { channel, sandboxes: pair } of overlaps) { + log( + ` ⚠ ${channel} is enabled on both '${pair[0]}' and '${pair[1]}'. Bot tokens only allow one sandbox to poll — both bridges will fail.`, + ); + } + log( + " Run `nemoclaw destroy` on whichever sandbox should stop polling, or rerun onboarding with the channel disabled.", + ); + } + } + + if (deps.checkMessagingBridgeHealth && defaultSandbox) { + // Re-fetch: backfillAndFindOverlaps above may have populated + // messagingChannels for the default sandbox on first run after upgrade, + // and the original `sandboxes` snapshot is stale. + const refreshed = deps.listSandboxes().sandboxes; + const defaultEntry = refreshed.find((sb) => sb.name === defaultSandbox); + const channels = defaultEntry?.messagingChannels; + if (Array.isArray(channels) && channels.length > 0) { + const degraded = deps.checkMessagingBridgeHealth(defaultSandbox, channels); + if (degraded.length > 0) { + log(""); + for (const { channel, conflicts } of degraded) { + log( + ` ⚠ ${channel} bridge: degraded (${conflicts} conflict errors in /tmp/gateway.log)`, + ); + } + log( + " Another sandbox is likely polling with the same bot token. See docs/reference/troubleshooting.md.", + ); + } + } + } } diff --git a/src/lib/messaging-conflict.test.ts b/src/lib/messaging-conflict.test.ts new file mode 100644 index 00000000000..65dbce100bf --- /dev/null +++ b/src/lib/messaging-conflict.test.ts @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import type { SandboxEntry } from "./registry"; +import { + backfillMessagingChannels, + findAllOverlaps, + findChannelConflicts, +} from "./messaging-conflict"; + +function makeRegistry(sandboxes: SandboxEntry[]) { + const store = new Map(sandboxes.map((s) => [s.name, { ...s }])); + return { + listSandboxes: () => ({ + sandboxes: Array.from(store.values()), + defaultSandbox: sandboxes[0]?.name ?? null, + }), + updateSandbox: vi.fn((name: string, updates: Partial) => { + const entry = store.get(name); + if (!entry) return false; + Object.assign(entry, updates); + return true; + }), + }; +} + +describe("findChannelConflicts", () => { + it("returns conflicts when another sandbox already has the channel", () => { + const registry = makeRegistry([ + { name: "alice", messagingChannels: ["telegram"] }, + { name: "bob", messagingChannels: [] }, + ]); + expect(findChannelConflicts("bob", ["telegram"], registry)).toEqual([ + { channel: "telegram", sandbox: "alice" }, + ]); + }); + + it("excludes the current sandbox from its own conflicts", () => { + const registry = makeRegistry([{ name: "alice", messagingChannels: ["telegram"] }]); + expect(findChannelConflicts("alice", ["telegram"], registry)).toEqual([]); + }); + + it("skips entries with no messagingChannels field (pre-backfill)", () => { + const registry = makeRegistry([{ name: "alice" }, { name: "bob", messagingChannels: [] }]); + expect(findChannelConflicts("bob", ["telegram"], registry)).toEqual([]); + }); + + it("returns empty when no channels are enabled", () => { + const registry = makeRegistry([{ name: "alice", messagingChannels: ["telegram"] }]); + expect(findChannelConflicts("bob", [], registry)).toEqual([]); + }); +}); + +describe("findAllOverlaps", () => { + it("reports each overlapping pair once", () => { + const registry = makeRegistry([ + { name: "alice", messagingChannels: ["telegram"] }, + { name: "bob", messagingChannels: ["telegram"] }, + { name: "carol", messagingChannels: ["discord"] }, + ]); + expect(findAllOverlaps(registry)).toEqual([ + { channel: "telegram", sandboxes: ["alice", "bob"] }, + ]); + }); + + it("reports all pairs when three sandboxes share a channel", () => { + const registry = makeRegistry([ + { name: "a", messagingChannels: ["telegram"] }, + { name: "b", messagingChannels: ["telegram"] }, + { name: "c", messagingChannels: ["telegram"] }, + ]); + expect(findAllOverlaps(registry)).toEqual([ + { channel: "telegram", sandboxes: ["a", "b"] }, + { channel: "telegram", sandboxes: ["a", "c"] }, + { channel: "telegram", sandboxes: ["b", "c"] }, + ]); + }); + + it("returns empty when channels do not overlap", () => { + const registry = makeRegistry([ + { name: "alice", messagingChannels: ["telegram"] }, + { name: "bob", messagingChannels: ["discord"] }, + ]); + expect(findAllOverlaps(registry)).toEqual([]); + }); +}); + +describe("backfillMessagingChannels", () => { + it("fills in missing messagingChannels by probing OpenShell", () => { + const registry = makeRegistry([{ name: "alice" }]); + const probe = { + providerExists: vi.fn((name: string) => + name === "alice-telegram-bridge" ? "present" : "absent", + ) as (name: string) => "present" | "absent" | "error", + }; + backfillMessagingChannels(registry, probe); + expect(registry.updateSandbox).toHaveBeenCalledWith("alice", { + messagingChannels: ["telegram"], + }); + expect(probe.providerExists).toHaveBeenCalledWith("alice-telegram-bridge"); + expect(probe.providerExists).toHaveBeenCalledWith("alice-discord-bridge"); + expect(probe.providerExists).toHaveBeenCalledWith("alice-slack-bridge"); + }); + + it("leaves entries with existing messagingChannels alone", () => { + const registry = makeRegistry([ + { name: "alice", messagingChannels: ["telegram"] }, + ]); + const probe = { + providerExists: vi.fn(() => "present") as (name: string) => "present" | "absent" | "error", + }; + backfillMessagingChannels(registry, probe); + expect(registry.updateSandbox).not.toHaveBeenCalled(); + expect(probe.providerExists).not.toHaveBeenCalled(); + }); + + it("writes an empty array when all probes return absent", () => { + const registry = makeRegistry([{ name: "alice" }]); + const probe = { + providerExists: vi.fn(() => "absent") as (name: string) => "present" | "absent" | "error", + }; + backfillMessagingChannels(registry, probe); + expect(registry.updateSandbox).toHaveBeenCalledWith("alice", { messagingChannels: [] }); + }); + + it("does NOT persist when a probe returns error (retry on next call)", () => { + // "error" is distinct from "absent": a transient gateway failure must not + // be collapsed into "provider not attached" and persisted, because that + // would prevent all future backfill retries and hide real overlaps. + const registry = makeRegistry([{ name: "alice" }]); + const probe = { + providerExists: vi.fn((name: string) => { + if (name.endsWith("-telegram-bridge")) return "error"; + return name.endsWith("-discord-bridge") ? "present" : "absent"; + }) as (name: string) => "present" | "absent" | "error", + }; + backfillMessagingChannels(registry, probe); + expect(registry.updateSandbox).not.toHaveBeenCalled(); + }); + + it("also treats a thrown probe as error (defensive; callers should return 'error' instead)", () => { + const registry = makeRegistry([{ name: "alice" }]); + const probe = { + providerExists: vi.fn(() => { + throw new Error("unexpected"); + }) as (name: string) => "present" | "absent" | "error", + }; + backfillMessagingChannels(registry, probe); + expect(registry.updateSandbox).not.toHaveBeenCalled(); + }); + + it("re-attempts backfill on a subsequent call after a prior error", () => { + const registry = makeRegistry([{ name: "alice" }]); + let firstPass = true; + const probe = { + providerExists: vi.fn((name: string) => { + if (name.endsWith("-telegram-bridge") && firstPass) { + firstPass = false; + return "error"; + } + return name === "alice-telegram-bridge" ? "present" : "absent"; + }) as (name: string) => "present" | "absent" | "error", + }; + backfillMessagingChannels(registry, probe); + expect(registry.updateSandbox).not.toHaveBeenCalled(); + backfillMessagingChannels(registry, probe); + expect(registry.updateSandbox).toHaveBeenCalledWith("alice", { + messagingChannels: ["telegram"], + }); + }); +}); diff --git a/src/lib/messaging-conflict.ts b/src/lib/messaging-conflict.ts new file mode 100644 index 00000000000..c37cfc7568e --- /dev/null +++ b/src/lib/messaging-conflict.ts @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Cross-sandbox messaging-channel conflict detection. +// +// Telegram (getUpdates long-polling), Discord (gateway connection), and Slack +// (Socket Mode) all enforce one active consumer per bot token. Two sandboxes +// sharing the same token silently break both bridges; see issue #1953. +// +// The registry persists which channels each sandbox uses. This module detects +// overlaps and — because pre-existing sandboxes created before the field was +// added have no record — can optionally backfill the field by probing the live +// OpenShell gateway for known provider names. + +import type { SandboxEntry } from "./registry"; + +type ProbeResult = "present" | "absent" | "error"; + +interface ConflictProbe { + // Tri-state — "error" is distinct from "absent" so a transient gateway + // failure does not get collapsed into "provider not attached" and then + // persisted as a bogus empty messagingChannels. + providerExists: (name: string) => ProbeResult; +} + +interface ConflictRegistry { + listSandboxes: () => { sandboxes: SandboxEntry[]; defaultSandbox?: string | null }; + updateSandbox: (name: string, updates: Partial) => boolean; +} + +interface Conflict { + channel: string; + sandbox: string; +} + +// NemoClaw attaches one OpenShell provider per messaging channel per sandbox. +// The provider name pattern is established in src/lib/onboard.ts at sandbox +// creation time; when a sandbox predates the messagingChannels registry field, +// the live provider is the only record of which channels it uses. +const PROVIDER_SUFFIXES: Record = { + telegram: "-telegram-bridge", + discord: "-discord-bridge", + slack: "-slack-bridge", +}; + +const KNOWN_CHANNELS = Object.keys(PROVIDER_SUFFIXES); + +/** + * For registry entries missing `messagingChannels`, probe OpenShell to infer + * which channels the sandbox was onboarded with, and write the result back to + * the registry. Safe to call repeatedly — entries with the field set are left + * alone. Failures to probe any one sandbox are swallowed so that a flaky + * gateway does not block status or onboarding. + */ +export function backfillMessagingChannels( + registry: ConflictRegistry, + probe: ConflictProbe, +): void { + const { sandboxes } = registry.listSandboxes(); + for (const entry of sandboxes) { + if (Array.isArray(entry.messagingChannels)) continue; + const discovered: string[] = []; + let probeFailed = false; + for (const channel of KNOWN_CHANNELS) { + const providerName = `${entry.name}${PROVIDER_SUFFIXES[channel]}`; + let state: ProbeResult; + try { + state = probe.providerExists(providerName); + } catch { + state = "error"; + } + if (state === "present") { + discovered.push(channel); + } else if (state === "error") { + // Partial results can't be persisted: writing a partial/empty list + // sets messagingChannels, preventing future retries and permanently + // hiding real overlaps. Skip the write so we retry on next call. + probeFailed = true; + break; + } + } + if (!probeFailed) { + registry.updateSandbox(entry.name, { messagingChannels: discovered }); + } + } +} + +/** + * Return every (channel, other-sandbox) pair where another sandbox in the + * registry already has one of the `enabledChannels` in use. + */ +export function findChannelConflicts( + currentSandbox: string | null, + enabledChannels: string[], + registry: ConflictRegistry, +): Conflict[] { + if (!Array.isArray(enabledChannels) || enabledChannels.length === 0) return []; + const { sandboxes } = registry.listSandboxes(); + const others = sandboxes.filter( + (s) => s.name !== currentSandbox && Array.isArray(s.messagingChannels), + ); + return enabledChannels.flatMap((channel) => + others + .filter((s) => (s.messagingChannels || []).includes(channel)) + .map((s) => ({ channel, sandbox: s.name })), + ); +} + +/** + * Detect overlaps across every sandbox in the registry, returning each pair at + * most once. Used by `nemoclaw status` to warn users whose sandboxes already + * share a messaging token. + */ +export function findAllOverlaps(registry: ConflictRegistry): Array<{ + channel: string; + sandboxes: [string, string]; +}> { + const { sandboxes } = registry.listSandboxes(); + const byChannel = new Map(); + for (const entry of sandboxes) { + if (!Array.isArray(entry.messagingChannels)) continue; + for (const channel of entry.messagingChannels) { + const list = byChannel.get(channel) || []; + list.push(entry.name); + byChannel.set(channel, list); + } + } + const overlaps: Array<{ channel: string; sandboxes: [string, string] }> = []; + for (const [channel, names] of byChannel) { + if (names.length < 2) continue; + for (let i = 0; i < names.length; i += 1) { + for (let j = i + 1; j < names.length; j += 1) { + overlaps.push({ channel, sandboxes: [names[i], names[j]] }); + } + } + } + return overlaps; +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 6ab4d84480d..1197aaee2d2 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -792,6 +792,31 @@ function providerExistsInGateway(name) { return result.status === 0; } +// Tri-state probe factory for messaging-conflict backfill. An upfront liveness +// check is necessary because `openshell provider get` exits non-zero for both +// "provider not attached" and "gateway unreachable"; without the liveness +// gate, a transient gateway failure would be recorded as "no providers" and +// permanently suppress future backfill retries. +function makeConflictProbe() { + let gatewayAlive = null; + const isGatewayAlive = () => { + if (gatewayAlive === null) { + const result = runCaptureOpenshell(["sandbox", "list"], { ignoreError: true }); + // runCaptureOpenshell returns stdout/stderr as a single string; treat + // any non-empty output as a sign openshell answered. Empty output with + // ignoreError typically means the binary failed to produce anything. + gatewayAlive = typeof result === "string" && result.length > 0; + } + return gatewayAlive; + }; + return { + providerExists: (name) => { + if (!isGatewayAlive()) return "error"; + return providerExistsInGateway(name) ? "present" : "absent"; + }, + }; +} + function verifyInferenceRoute(_provider, _model) { const output = runCaptureOpenshell(["inference", "get"], { ignoreError: true }); if (!output || /Gateway inference:\s*[\r\n]+\s*Not configured/i.test(output)) { @@ -2702,6 +2727,49 @@ async function createSandbox( const getMessagingToken = (envKey) => getCredential(envKey) || normalizeCredentialValue(process.env[envKey]) || null; + // The UI toggle list can include channels the user toggled on but then + // skipped the token prompt for. Only channels with a real token will have a + // provider attached, so the conflict check must filter out the skipped ones + // (otherwise we warn about phantom channels that will never poll). + const conflictCheckChannels: string[] = Array.isArray(enabledChannels) + ? enabledChannels.filter((name) => { + const def = MESSAGING_CHANNELS.find((c) => c.name === name); + return def ? !!getMessagingToken(def.envKey) : false; + }) + : []; + + // Messaging channels like Telegram (getUpdates), Discord (gateway), and Slack + // (Socket Mode) enforce one consumer per bot token. Two sandboxes sharing + // a token silently break both bridges (see #1953). Warn before we commit. + if (conflictCheckChannels.length > 0) { + const { + backfillMessagingChannels, + findChannelConflicts, + } = require("./messaging-conflict"); + backfillMessagingChannels(registry, makeConflictProbe()); + const conflicts = findChannelConflicts(sandboxName, conflictCheckChannels, registry); + if (conflicts.length > 0) { + for (const { channel, sandbox } of conflicts) { + console.log( + ` ⚠ Sandbox '${sandbox}' already has ${channel} enabled. Bot tokens only allow one sandbox to poll — continuing will break both bridges.`, + ); + } + if (isNonInteractive()) { + console.error( + " Aborting: resolve the messaging channel conflict above or run `nemoclaw destroy` on the other sandbox.", + ); + process.exit(1); + } + const answer = (await promptOrDefault(" Continue anyway? [y/N]: ", null, "n")) + .trim() + .toLowerCase(); + if (answer !== "y" && answer !== "yes") { + console.log(" Aborting sandbox creation."); + process.exit(1); + } + } + } + // When enabledChannels is provided (from the toggle picker), only include // channels the user selected. When null (backward compat), include all. const enabledEnvKeys = @@ -3166,6 +3234,7 @@ async function createSandbox( agent: agent ? agent.name : null, agentVersion: fromDockerfile ? null : effectiveAgent.expectedVersion || null, dangerouslySkipPermissions: dangerouslySkipPermissions || undefined, + messagingChannels: activeMessagingChannels, }); // DNS proxy — run a forwarder in the sandbox pod so the isolated diff --git a/src/lib/registry.ts b/src/lib/registry.ts index f8365d67df4..3ed8be8a2c3 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -18,6 +18,7 @@ export interface SandboxEntry { agent?: string | null; dangerouslySkipPermissions?: boolean; agentVersion?: string | null; + messagingChannels?: string[]; } export interface SandboxRegistry { @@ -165,6 +166,7 @@ export function registerSandbox(entry: SandboxEntry): void { dangerouslySkipPermissions: entry.dangerouslySkipPermissions === true ? true : undefined, agentVersion: entry.agentVersion || null, + messagingChannels: entry.messagingChannels || [], }; if (!data.defaultSandbox) { data.defaultSandbox = entry.name; diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 64f9dd40865..1377e7cf2fd 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -1032,6 +1032,65 @@ async function credentialsCommand(args) { process.exit(1); } +function checkMessagingBridgeHealth(sandboxName, channels) { + // Only Telegram currently emits a recognizable conflict signature in the + // gateway log. Discord/Slack have similar single-consumer constraints but + // log differently; we can extend the regex when those patterns are known. + if (!Array.isArray(channels) || !channels.includes("telegram")) return []; + const { spawnSync } = require("child_process"); + const script = + 'tail -n 200 /tmp/gateway.log 2>/dev/null | grep -cE "getUpdates conflict|409[[:space:]:]+Conflict" || true'; + try { + const result = spawnSync( + getOpenshellBinary(), + ["sandbox", "exec", sandboxName, "sh", "-c", script], + { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "pipe"] }, + ); + const count = Number.parseInt((result.stdout || "").trim(), 10); + if (!Number.isFinite(count) || count === 0) return []; + return [{ channel: "telegram", conflicts: count }]; + } catch { + return []; + } +} + +function makeConflictProbe() { + // Upfront liveness check so we can distinguish "provider not attached" from + // "gateway unreachable". Without this, every non-zero `openshell provider + // get` collapses into "absent", and a transient gateway failure would + // persist messagingChannels: [] and permanently suppress future retries. + let gatewayAlive: boolean | null = null; + const isGatewayAlive = () => { + if (gatewayAlive === null) { + const result = captureOpenshell(["sandbox", "list"], { ignoreError: true }); + gatewayAlive = result.status === 0; + } + return gatewayAlive; + }; + return { + providerExists: (name) => { + if (!isGatewayAlive()) return "error"; + const result = captureOpenshell(["provider", "get", name], { ignoreError: true }); + return result.status === 0 ? "present" : "absent"; + }, + }; +} + +function backfillAndFindOverlaps() { + // Non-critical path: status must remain usable even if the gateway probe or + // registry write throws, so any failure yields an empty overlap list. + try { + const { + backfillMessagingChannels, + findAllOverlaps, + } = require("./lib/messaging-conflict"); + backfillMessagingChannels(registry, makeConflictProbe()); + return findAllOverlaps(registry); + } catch { + return []; + } +} + function showStatus() { const { showStatus: showServiceStatus } = require("./lib/services"); showStatusCommand({ @@ -1039,6 +1098,8 @@ function showStatus() { getLiveInference: () => parseGatewayInference(captureOpenshell(["inference", "get"], { ignoreError: true }).output), showServiceStatus, + checkMessagingBridgeHealth, + backfillAndFindOverlaps, log: console.log, }); }