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
9 changes: 8 additions & 1 deletion src/lib/actions/sandbox/gateway-restart-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ describe("Hermes MCP gateway restart", () => {
ok: false,
failureLayer: "MCP reconciliation refusal",
detail: "Hermes MCP config does not match persisted managed intent",
restarted: true,
healthPassed: true,
});
expect(deps.ensureSandboxPortForward).not.toHaveBeenCalled();
} finally {
Expand All @@ -75,6 +77,8 @@ describe("Hermes MCP gateway restart", () => {
ok: false,
failureLayer: "MCP reconciliation refusal",
detail: "integrity pending FORGED SUCCESS <REDACTED>",
restarted: true,
healthPassed: true,
});
expect(deps.ensureSandboxPortForward).not.toHaveBeenCalled();
} finally {
Expand All @@ -94,10 +98,13 @@ describe("Hermes MCP gateway restart", () => {
})),
});

expect(restartSandboxGateway("alpha", { quiet: true, deps })).toMatchObject({
const result = restartSandboxGateway("alpha", { quiet: true, deps });
expect(result).toMatchObject({
ok: false,
failureLayer: "MCP reconciliation refusal",
});
expect(result).not.toHaveProperty("restarted");
expect(result).not.toHaveProperty("healthPassed");
expect(deps.waitForRecoveredSandboxGateway).not.toHaveBeenCalled();
const output = vi.mocked(console.error).mock.calls.flat().join("\n");
expect(output).toContain("nemoclaw alpha mcp restart");
Expand Down
11 changes: 11 additions & 0 deletions src/lib/actions/sandbox/gateway-restart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ export type GatewayRestartResult =
ok: false;
failureLayer: GatewayRestartFailureLayer;
detail: string;
restarted?: never;
healthPassed?: never;
}
| {
ok: false;
failureLayer: "MCP reconciliation refusal";
detail: string;
restarted: true;
healthPassed: true;
};

type SandboxAgentLookup = (sandboxName: string) => { agent?: string | null } | null | undefined;
Expand Down Expand Up @@ -430,6 +439,8 @@ export function restartSandboxGatewayWithDeps(
ok: false,
failureLayer: "MCP reconciliation refusal",
detail,
restarted: true,
healthPassed: true,
};
}
}
Expand Down
64 changes: 64 additions & 0 deletions src/lib/actions/sandbox/rebuild-hermes-post-restore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import {
ensureHermesGatewayAfterStateRestore,
ensureHermesGatewayAfterStateRestoreForCronGate,
restartHermesGatewayAfterStateRestore,
verifyHermesGatewayAfterStateRestore,
verifyHermesGatewayAfterStateRestoreForCronGate,
} from "./rebuild-hermes-post-restore";
Expand All @@ -25,6 +26,18 @@ const RESTART_FAILED = {
failureLayer: "health timeout",
detail: "gateway did not become healthy",
} as const;
const RESTARTED_WITH_MCP_MISMATCH = {
ok: false,
failureLayer: "MCP reconciliation refusal",
detail: "Hermes MCP config does not match persisted managed intent",
restarted: true,
healthPassed: true,
} as const;
const MCP_REFUSED_BEFORE_RESTART = {
ok: false,
failureLayer: "MCP reconciliation refusal",
detail: "supervisor refused the restart before replacing the gateway",
} as const;

describe("binding the Hermes gateway to restored state", () => {
it("restarts the gateway before reading its health (#8184)", () => {
Expand Down Expand Up @@ -73,6 +86,57 @@ describe("binding the Hermes gateway to restored state", () => {
expect(state).toBe("recovered");
});

it("keeps restart evidence while rebuild restores the managed MCP projection (#8671)", () => {
const restartState = restartHermesGatewayAfterStateRestore("alpha", "hermes", {
restartSandboxGateway: () => RESTARTED_WITH_MCP_MISMATCH,
});

expect(restartState).toBe("restarted");
expect(
verifyHermesGatewayAfterStateRestore("alpha", "hermes", restartState, {
checkAndRecoverSandboxProcesses: () => ({
checked: true,
wasRunning: true,
recovered: false,
}),
}),
).toBe("healthy");
});

it("rejects managed MCP drift that remains after rebuild restoration (#8671)", () => {
const restartState = restartHermesGatewayAfterStateRestore("alpha", "hermes", {
restartSandboxGateway: () => RESTARTED_WITH_MCP_MISMATCH,
});

expect(
verifyHermesGatewayAfterStateRestore("alpha", "hermes", restartState, {
checkAndRecoverSandboxProcesses: () => ({
checked: true,
wasRunning: true,
recovered: false,
mcpReconciliationRefused: true,
}),
}),
).toBe("unverified");
});

it("preserves an MCP refusal before gateway replacement (#8671)", () => {
const restartState = restartHermesGatewayAfterStateRestore("alpha", "hermes", {
restartSandboxGateway: () => MCP_REFUSED_BEFORE_RESTART,
});

expect(restartState).toBe("restart-failed");
expect(
verifyHermesGatewayAfterStateRestore("alpha", "hermes", restartState, {
checkAndRecoverSandboxProcesses: () => ({
checked: true,
wasRunning: true,
recovered: false,
}),
}),
).toBe("unverified");
});

it("leaves a non-Hermes rebuild without a gateway restart (#8184)", () => {
const restartSandboxGateway = vi.fn(() => RESTART_SUCCEEDED);
const checkAndRecoverSandboxProcesses = vi.fn(() => ({
Expand Down
9 changes: 8 additions & 1 deletion src/lib/actions/sandbox/rebuild-hermes-post-restore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,14 @@ export function restartHermesGatewayAfterStateRestore(
): HermesPostRestoreGatewayRestartState {
if (agentName !== "hermes") return "not-applicable";
const restart = deps.restartSandboxGateway ?? processRecovery.restartSandboxGateway;
return restart(sandboxName, { quiet: true }).ok ? "restarted" : "restart-failed";
const result = restart(sandboxName, { quiet: true });
if (result.ok) return "restarted";
const mcpRestoreCanSupersede =
result.failureLayer === "MCP reconciliation refusal" &&
result.restarted === true &&
result.healthPassed === true;
// Final verification still requires MCP reconciliation after restoration.
return mcpRestoreCanSupersede ? "restarted" : "restart-failed";
}

export function verifyHermesGatewayAfterStateRestore(
Expand Down
Loading