Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sandbox-name>
$ 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 <other-sandbox> 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+).
Expand Down
19 changes: 19 additions & 0 deletions docs/reference/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sandbox-name>
$ 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 <other-sandbox> 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+).
Expand Down
71 changes: 71 additions & 0 deletions src/lib/inventory-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
54 changes: 54 additions & 0 deletions src/lib/inventory-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}

Expand Down Expand Up @@ -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 <sandbox> 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.",
);
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
173 changes: 173 additions & 0 deletions src/lib/messaging-conflict.test.ts
Original file line number Diff line number Diff line change
@@ -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<SandboxEntry>) => {
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"],
});
});
});
Loading
Loading